diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 927b551..ec6c89a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: VERSION=$(grep -m 1 "_VERSION" FlexLove.lua | awk -F'"' '{print $2}') echo "Triggered manually, using FlexLove.lua version" fi - + # Verify version was extracted if [ -z "$VERSION" ]; then echo "ERROR: Failed to extract version from FlexLove.lua" @@ -37,7 +37,7 @@ jobs: grep "_VERSION" FlexLove.lua || echo "No _VERSION found" exit 1 fi - + echo "Extracted version: $VERSION" echo "version=$VERSION" >> $GITHUB_OUTPUT echo "tag=v${VERSION}" >> $GITHUB_OUTPUT @@ -128,7 +128,7 @@ jobs: echo "Contents:" ls -la echo "" - + # Create all 4 profile packages ./scripts/create-profile-packages.sh @@ -180,10 +180,10 @@ jobs: VERSION="${{ steps.version.outputs.version }}" CURRENT_TAG="v${VERSION}" REPO="${{ github.repository }}" - + # Get the previous tag (exclude current tag if it exists) PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^${CURRENT_TAG}$" | head -1) - + # Generate changelog if [ -n "$PREVIOUS_TAG" ]; then echo "## Changes since $PREVIOUS_TAG" > release_notes.md @@ -201,7 +201,7 @@ jobs: echo "" >> release_notes.md echo "" >> release_notes.md fi - + # Append the rest of the release notes cat >> release_notes.md << 'EOF' ## Build Profiles @@ -212,7 +212,7 @@ jobs: |---------|------|-------------|---------| | **Minimal** | ~70% | Core functionality only | `flexlove-minimal-v${{ steps.version.outputs.version }}.zip` | | **Slim** | ~80% | + Animation and Image support | `flexlove-slim-v${{ steps.version.outputs.version }}.zip` | - | **Default** | ~95% | + Theme and Blur effects | `flexlove-default-v${{ steps.version.outputs.version }}.zip` | + | **Default** | ~95% | + Theme, Calc, Blur effects | `flexlove-default-v${{ steps.version.outputs.version }}.zip` | | **Full** | 100% | All modules including debugging tools | `flexlove-full-v${{ steps.version.outputs.version }}.zip` | **Choose the profile that matches your needs!** See the [Build Profiles Documentation](https://github.com/${{ github.repository }}/blob/main/docs/BUILD_PROFILES.md) for detailed module listings. diff --git a/FlexLove.lua b/FlexLove.lua index ef3705f..ba63b5d 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -20,6 +20,7 @@ end -- Required core modules local utils = req("utils") +local Calc = req("Calc") local Units = req("Units") local Context = req("Context") ---@type StateManager @@ -176,7 +177,8 @@ function flexlove.init(config) end -- Initialize required modules - Units.init({ Context = Context, ErrorHandler = flexlove._ErrorHandler }) + Calc.init({ ErrorHandler = flexlove._ErrorHandler }) + Units.init({ Context = Context, ErrorHandler = flexlove._ErrorHandler, Calc = Calc }) Color.init({ ErrorHandler = flexlove._ErrorHandler, FFI = flexlove._FFI }) utils.init({ ErrorHandler = flexlove._ErrorHandler }) @@ -197,6 +199,7 @@ function flexlove.init(config) Context = Context, Theme = Theme, Color = Color, + Calc = Calc, Units = Units, Blur = Blur, ImageRenderer = ImageRenderer, @@ -1103,6 +1106,21 @@ function flexlove.getStateStats() return StateManager.getStats() end +--- Create a calc() expression for dynamic CSS-like calculations +--- Use this to create responsive layouts that adapt to viewport and parent dimensions +--- @usage +--- local button = FlexLove.new({ +--- x = FlexLove.calc("50% - 10vw"), +--- y = FlexLove.calc("50% - 5vh"), +--- width = "20vw", +--- height = "10vh", +--- }) +---@param expr string The calc expression (e.g., "50% - 10vw", "100px + 20%") +---@return table calcObject A calc expression object that will be evaluated during layout +function flexlove.calc(expr) + return Calc.new(expr) +end + flexlove.Animation = Animation flexlove.Color = Color flexlove.Theme = Theme diff --git a/README.md b/README.md index feabdf3..d5aa7de 100644 --- a/README.md +++ b/README.md @@ -110,25 +110,6 @@ FlexLöve supports optional modules to reduce bundle size for different use case - **Default (~95%)** - Adds themes, blur effects, and gestures - **Full (100%)** - Everything including performance monitoring -### Example: Minimal Build - -For a lightweight build, exclude these optional module files: -``` -modules/Animation.lua -modules/Theme.lua -modules/Blur.lua -modules/ImageRenderer.lua -modules/ImageScaler.lua -modules/ImageCache.lua -modules/NinePatch.lua -modules/GestureRecognizer.lua -modules/Performance.lua -``` - -The library automatically detects missing modules and provides safe no-op stubs. No code changes needed! - -📖 **See [BUILD_PROFILES.md](./docs/BUILD_PROFILES.md) and [MODULE_DEPENDENCIES.md](./docs/MODULE_DEPENDENCIES.md) for detailed information.** - ## Documentation 📚 **[View Full API Documentation](https://mikefreno.github.io/FlexLove/api.html)** @@ -574,6 +555,39 @@ local element = FlexLove.new({ }) ``` +#### Dynamic Calculations with calc() + +Use `calc()` for CSS-like dynamic calculations in layout properties: + +```lua +-- Center a button horizontally (accounting for its width) +local button = FlexLove.new({ + x = FlexLove.calc("50% - 10vw"), -- Centers a 20vw wide button + y = "50vh", + width = "20vw", + height = "10vh", + text = "Centered Button" +}) + +-- Complex calculations with multiple operations +local sidebar = FlexLove.new({ + width = FlexLove.calc("100vw - 300px"), -- Full width minus fixed sidebar + height = FlexLove.calc("100vh - 50px"), -- Full height minus header + x = "300px", + y = "50px" +}) + +-- Using parentheses for order of operations +local panel = FlexLove.new({ + width = FlexLove.calc("(100vw - 40px) / 3"), -- Three equal columns with 40px total padding + padding = { left = "10px", right = "10px" } +}) +``` + +**Supported operations:** `+`, `-`, `*`, `/` +**Supported units:** `px`, `%`, `vw`, `vh`, `ew`, `eh` +**Note:** Element width/height units (`ew`, `eh`) cannot be used in position calculations (`x`, `y`) due to circular dependencies. + ### Animations Create smooth transitions: diff --git a/RELEASE.md b/RELEASE.md index 4b24e37..17dad75 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -174,10 +174,8 @@ Each profile package includes: |---------|---------|------------------| | **Minimal** | 19 core modules | ~70% of full | | **Slim** | 24 modules | ~80% of full | -| **Default** | 27 modules + themes | ~95% of full | -| **Full** | 29 modules + themes | 100% | - -**Note:** All profiles now include UTF8.lua for Lua 5.1+ compatibility. +| **Default** | 28 modules + theme examples | ~95% of full | +| **Full** | 30 modules + theme examples | 100% | Users who want examples, documentation source, or development tools should clone the full repository. diff --git a/modules/Calc.lua b/modules/Calc.lua new file mode 100644 index 0000000..e2ac744 --- /dev/null +++ b/modules/Calc.lua @@ -0,0 +1,404 @@ +--- Utility module for parsing and evaluating CSS-like calc() expressions +--- Supports arithmetic operations (+, -, *, /) with mixed units (px, %, vw, vh, ew, eh) +---@class Calc +local Calc = {} + +--- Initialize Calc module with dependencies +---@param deps table Dependencies: { ErrorHandler = table? } +function Calc.init(deps) + Calc._ErrorHandler = deps.ErrorHandler +end + +--- Token types for lexical analysis +local TokenType = { + NUMBER = "NUMBER", + UNIT = "UNIT", + PLUS = "PLUS", + MINUS = "MINUS", + MULTIPLY = "MULTIPLY", + DIVIDE = "DIVIDE", + LPAREN = "LPAREN", + RPAREN = "RPAREN", + EOF = "EOF", +} + +--- Tokenize a calc expression string into tokens +---@param expr string The expression to tokenize (e.g., "50% - 10vw") +---@return table|nil tokens Array of tokens with type, value, unit +---@return string? error Error message if tokenization fails +local function tokenize(expr) + local tokens = {} + local i = 1 + local len = #expr + + while i <= len do + local char = expr:sub(i, i) + + -- Skip whitespace + if char:match("%s") then + i = i + 1 + -- Number (including decimals, but NOT negative - handled separately below) + elseif char:match("%d") or (char == "." and expr:sub(i + 1, i + 1):match("%d")) then + local numStr = "" + + -- Parse integer and decimal parts + while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do + numStr = numStr .. expr:sub(i, i) + i = i + 1 + end + + local num = tonumber(numStr) + if not num then + return nil, "Invalid number: " .. numStr + end + + -- Check for unit following the number + local unitStr = "" + while i <= len and expr:sub(i, i):match("[%a%%]") do + unitStr = unitStr .. expr:sub(i, i) + i = i + 1 + end + + -- Default to px if no unit + if unitStr == "" then + unitStr = "px" + end + + -- Validate unit + local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true } + if not validUnits[unitStr] then + return nil, "Invalid unit: " .. unitStr + end + + table.insert(tokens, { + type = TokenType.NUMBER, + value = num, + unit = unitStr, + }) + -- Operators + elseif char == "+" then + table.insert(tokens, { type = TokenType.PLUS }) + i = i + 1 + elseif char == "-" then + -- Check if this is a negative number or subtraction + -- It's a negative number if previous token is an operator or opening paren + local prevToken = tokens[#tokens] + if not prevToken or prevToken.type == TokenType.PLUS or prevToken.type == TokenType.MINUS + or prevToken.type == TokenType.MULTIPLY or prevToken.type == TokenType.DIVIDE + or prevToken.type == TokenType.LPAREN then + -- This is a negative number, continue to number parsing + local numStr = "-" + i = i + 1 + + -- Parse integer and decimal parts + while i <= len and (expr:sub(i, i):match("%d") or expr:sub(i, i) == ".") do + numStr = numStr .. expr:sub(i, i) + i = i + 1 + end + + local num = tonumber(numStr) + if not num then + return nil, "Invalid number: " .. numStr + end + + -- Check for unit following the number + local unitStr = "" + while i <= len and expr:sub(i, i):match("[%a%%]") do + unitStr = unitStr .. expr:sub(i, i) + i = i + 1 + end + + -- Default to px if no unit + if unitStr == "" then + unitStr = "px" + end + + -- Validate unit + local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true } + if not validUnits[unitStr] then + return nil, "Invalid unit: " .. unitStr + end + + table.insert(tokens, { + type = TokenType.NUMBER, + value = num, + unit = unitStr, + }) + else + -- This is subtraction operator + table.insert(tokens, { type = TokenType.MINUS }) + i = i + 1 + end + elseif char == "*" then + table.insert(tokens, { type = TokenType.MULTIPLY }) + i = i + 1 + elseif char == "/" then + table.insert(tokens, { type = TokenType.DIVIDE }) + i = i + 1 + elseif char == "(" then + table.insert(tokens, { type = TokenType.LPAREN }) + i = i + 1 + elseif char == ")" then + table.insert(tokens, { type = TokenType.RPAREN }) + i = i + 1 + else + return nil, "Unexpected character: " .. char + end + end + + table.insert(tokens, { type = TokenType.EOF }) + return tokens +end + +--- Parser for calc expressions using recursive descent +---@class Parser +---@field tokens table Array of tokens +---@field pos number Current token position +local Parser = {} +Parser.__index = Parser + +--- Create a new parser +---@param tokens table Array of tokens +---@return Parser +function Parser.new(tokens) + local self = setmetatable({}, Parser) + self.tokens = tokens + self.pos = 1 + return self +end + +--- Get current token +---@return table token Current token +function Parser:current() + return self.tokens[self.pos] +end + +--- Advance to next token +function Parser:advance() + self.pos = self.pos + 1 +end + +--- Parse expression (handles + and -) +---@return table ast Abstract syntax tree node +function Parser:parseExpression() + local left = self:parseTerm() + + while self:current().type == TokenType.PLUS or self:current().type == TokenType.MINUS do + local op = self:current().type + self:advance() + local right = self:parseTerm() + left = { + type = op == TokenType.PLUS and "add" or "subtract", + left = left, + right = right, + } + end + + return left +end + +--- Parse term (handles * and /) +---@return table ast Abstract syntax tree node +function Parser:parseTerm() + local left = self:parseFactor() + + while self:current().type == TokenType.MULTIPLY or self:current().type == TokenType.DIVIDE do + local op = self:current().type + self:advance() + local right = self:parseFactor() + left = { + type = op == TokenType.MULTIPLY and "multiply" or "divide", + left = left, + right = right, + } + end + + return left +end + +--- Parse factor (handles numbers and parentheses) +---@return table ast Abstract syntax tree node +function Parser:parseFactor() + local token = self:current() + + if token.type == TokenType.NUMBER then + self:advance() + return { + type = "number", + value = token.value, + unit = token.unit, + } + elseif token.type == TokenType.LPAREN then + self:advance() + local expr = self:parseExpression() + if self:current().type ~= TokenType.RPAREN then + error("Expected closing parenthesis") + end + self:advance() + return expr + else + error("Unexpected token: " .. token.type) + end +end + +--- Parse the tokens into an AST +---@return table ast Abstract syntax tree +function Parser:parse() + local ast = self:parseExpression() + if self:current().type ~= TokenType.EOF then + error("Unexpected tokens after expression") + end + return ast +end + +--- Create a calc expression object that can be resolved later +--- This is the main API function that users call +---@param expr string The calc expression (e.g., "50% - 10vw") +---@return table calcObject A calc expression object with AST +function Calc.new(expr) + -- Tokenize + local tokens, err = tokenize(expr) + if not tokens then + if Calc._ErrorHandler then + Calc._ErrorHandler:warn("Calc", "VAL_006", { + expression = expr, + error = err, + }) + end + -- Return a fallback calc object that resolves to 0 + return { + _isCalc = true, + _expr = expr, + _ast = nil, + _error = err, + } + end + + -- Parse + local parser = Parser.new(tokens) + local success, ast = pcall(function() + return parser:parse() + end) + + if not success then + if Calc._ErrorHandler then + Calc._ErrorHandler:warn("Calc", "VAL_006", { + expression = expr, + error = ast, -- ast contains error message on failure + }) + end + -- Return a fallback calc object that resolves to 0 + return { + _isCalc = true, + _expr = expr, + _ast = nil, + _error = ast, + } + end + + return { + _isCalc = true, + _expr = expr, + _ast = ast, + } +end + +--- Check if a value is a calc expression +---@param value any The value to check +---@return boolean isCalc True if value is a calc expression +function Calc.isCalc(value) + return type(value) == "table" and value._isCalc == true +end + +--- Resolve a calc expression to pixel value +---@param calcObj table The calc expression object +---@param viewportWidth number Viewport width in pixels +---@param viewportHeight number Viewport height in pixels +---@param parentSize number? Parent dimension for percentage units +---@param elementWidth number? Element width for ew units +---@param elementHeight number? Element height for eh units +---@return number resolvedValue Resolved pixel value +function Calc.resolve(calcObj, viewportWidth, viewportHeight, parentSize, elementWidth, elementHeight) + if not calcObj._ast then + -- Error during parsing, return 0 + return 0 + end + + --- Evaluate AST node recursively + ---@param node table AST node + ---@return number value Evaluated value in pixels + local function evaluate(node) + if node.type == "number" then + -- Convert unit to pixels + local value = node.value + local unit = node.unit + + if unit == "px" then + return value + elseif unit == "%" then + if not parentSize then + if Calc._ErrorHandler then + Calc._ErrorHandler:warn("Calc", "LAY_003", { + unit = "%", + issue = "parent dimension not available", + }) + end + return 0 + end + return (value / 100) * parentSize + elseif unit == "vw" then + return (value / 100) * viewportWidth + elseif unit == "vh" then + return (value / 100) * viewportHeight + elseif unit == "ew" then + if not elementWidth then + if Calc._ErrorHandler then + Calc._ErrorHandler:warn("Calc", "LAY_003", { + unit = "ew", + issue = "element width not available", + }) + end + return 0 + end + return (value / 100) * elementWidth + elseif unit == "eh" then + if not elementHeight then + if Calc._ErrorHandler then + Calc._ErrorHandler:warn("Calc", "LAY_003", { + unit = "eh", + issue = "element height not available", + }) + end + return 0 + end + return (value / 100) * elementHeight + else + return 0 + end + elseif node.type == "add" then + return evaluate(node.left) + evaluate(node.right) + elseif node.type == "subtract" then + return evaluate(node.left) - evaluate(node.right) + elseif node.type == "multiply" then + return evaluate(node.left) * evaluate(node.right) + elseif node.type == "divide" then + local divisor = evaluate(node.right) + if divisor == 0 then + if Calc._ErrorHandler then + Calc._ErrorHandler:warn("Calc", "VAL_006", { + expression = calcObj._expr, + error = "Division by zero", + }) + end + return 0 + end + return evaluate(node.left) / divisor + else + return 0 + end + end + + return evaluate(calcObj._ast) +end + +return Calc diff --git a/modules/Element.lua b/modules/Element.lua index b37ba5f..ec4cf27 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -1092,7 +1092,7 @@ function Element.new(props) -- Handle x position with units if props.x then - if type(props.x) == "string" then + if type(props.x) == "string" or type(props.x) == "table" then local value, unit = Element._Units.parse(props.x) self.units.x = { value = value, unit = unit } self.x = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) @@ -1108,7 +1108,7 @@ function Element.new(props) -- Handle y position with units if props.y then - if type(props.y) == "string" then + if type(props.y) == "string" or type(props.y) == "table" then local value, unit = Element._Units.parse(props.y) self.units.y = { value = value, unit = unit } self.y = Element._Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) @@ -1181,7 +1181,7 @@ function Element.new(props) -- Handle x position with units if props.x then - if type(props.x) == "string" then + if type(props.x) == "string" or type(props.x) == "table" then local value, unit = Element._Units.parse(props.x) self.units.x = { value = value, unit = unit } local parentWidth = self.parent.width @@ -1200,7 +1200,7 @@ function Element.new(props) -- Handle y position with units if props.y then - if type(props.y) == "string" then + if type(props.y) == "string" or type(props.y) == "table" then local value, unit = Element._Units.parse(props.y) self.units.y = { value = value, unit = unit } local parentHeight = self.parent.height @@ -1225,7 +1225,7 @@ function Element.new(props) local baseY = self.parent.y + self.parent.padding.top if props.x then - if type(props.x) == "string" then + if type(props.x) == "string" or type(props.x) == "table" then local value, unit = Element._Units.parse(props.x) self.units.x = { value = value, unit = unit } local parentWidth = self.parent.width @@ -1243,7 +1243,7 @@ function Element.new(props) end if props.y then - if type(props.y) == "string" then + if type(props.y) == "string" or type(props.y) == "table" then local value, unit = Element._Units.parse(props.y) self.units.y = { value = value, unit = unit } parentHeight = self.parent.height diff --git a/modules/ErrorHandler.lua b/modules/ErrorHandler.lua index 7961282..3f3be04 100644 --- a/modules/ErrorHandler.lua +++ b/modules/ErrorHandler.lua @@ -46,8 +46,8 @@ local ErrorCodes = { VAL_006 = { code = "FLEXLOVE_VAL_006", category = "VAL", - description = "Invalid file path", - suggestion = "Check that the file path is correct and the file exists", + description = "Invalid calc() expression or calculation error", + suggestion = "Check calc() syntax and ensure no division by zero. Format: calc('value1 operator value2') with operators: +, -, *, / and units: px, %, vw, vh, ew, eh", }, VAL_007 = { code = "FLEXLOVE_VAL_007", diff --git a/modules/Units.lua b/modules/Units.lua index 92c8e60..7a93cf6 100644 --- a/modules/Units.lua +++ b/modules/Units.lua @@ -3,29 +3,36 @@ ---@class Units ---@field _Context table? Context module dependency ---@field _ErrorHandler table? ErrorHandler module dependency +---@field _Calc table? Calc module dependency local Units = {} --- Initialize Units module with dependencies ----@param deps table Dependencies: { Context = table?, ErrorHandler = table? } +---@param deps table Dependencies: { Context = table?, ErrorHandler = table?, Calc = table? } function Units.init(deps) Units._Context = deps.Context Units._ErrorHandler = deps.ErrorHandler + Units._Calc = deps.Calc end --- Parse a unit value into numeric value and unit type ---- Supports: px (pixels), % (percentage), vw/vh (viewport), ew/eh (element) ----@param value string|number The value to parse (e.g., "50px", "10%", "2vw", 100) ----@return number numericValue The numeric portion of the value ----@return string unitType The unit type ("px", "%", "vw", "vh", "ew", "eh") +--- Supports: px (pixels), % (percentage), vw/vh (viewport), ew/eh (element), and calc() expressions +---@param value string|number|table The value to parse (e.g., "50px", "10%", "2vw", 100, or calc object) +---@return number|table numericValue The numeric portion of the value or calc object +---@return string unitType The unit type ("px", "%", "vw", "vh", "ew", "eh", "calc") function Units.parse(value) + -- Check if value is a calc expression + if Units._Calc and Units._Calc.isCalc(value) then + return value, "calc" + end + if type(value) == "number" then return value, "px" end - if type(value) ~= "string" then + if type(value) ~= "string" and type(value) ~= "table" then Units._ErrorHandler:warn("Units", "VAL_001", { property = "unit value", - expected = "string or number", + expected = "string, number, or calc object", got = type(value), }) return 0, "px" @@ -87,15 +94,28 @@ function Units.parse(value) end --- Convert relative units to absolute pixel values ---- Resolves %, vw, vh units based on viewport and parent dimensions ----@param value number Numeric value to convert ----@param unit string Unit type ("px", "%", "vw", "vh", "ew", "eh") +--- Resolves %, vw, vh units based on viewport and parent dimensions, and evaluates calc() expressions +---@param value number|table Numeric value to convert or calc object +---@param unit string Unit type ("px", "%", "vw", "vh", "ew", "eh", "calc") ---@param viewportWidth number Current viewport width in pixels ---@param viewportHeight number Current viewport height in pixels ---@param parentSize number? Required for percentage units (parent dimension in pixels) +---@param elementWidth number? Required for ew units in calc expressions (element width in pixels) +---@param elementHeight number? Required for eh units in calc expressions (element height in pixels) ---@return number resolvedValue Resolved pixel value -function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize) - if unit == "px" then +function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize, elementWidth, elementHeight) + if unit == "calc" then + -- Resolve calc expression + if Units._Calc then + return Units._Calc.resolve(value, viewportWidth, viewportHeight, parentSize, elementWidth, elementHeight) + else + Units._ErrorHandler:warn("Units", "VAL_006", { + unit = "calc", + issue = "Calc module not available", + }) + return 0 + end + elseif unit == "px" then return value elseif unit == "%" then if not parentSize then @@ -113,7 +133,7 @@ function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize) else Units._ErrorHandler:warn("Units", "VAL_005", { unit = unit, - validUnits = "px, %, vw, vh, ew, eh", + validUnits = "px, %, vw, vh, ew, eh, calc", }) return 0 end @@ -169,26 +189,26 @@ function Units.resolveSpacing(spacingProps, parentWidth, parentHeight) local horizontal = spacingProps.horizontal if vertical then - if type(vertical) == "string" then + if type(vertical) == "string" or (Units._Calc and Units._Calc.isCalc(vertical)) then local value, unit = Units.parse(vertical) - vertical = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) + vertical = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight, nil, nil) end end if horizontal then - if type(horizontal) == "string" then + if type(horizontal) == "string" or (Units._Calc and Units._Calc.isCalc(horizontal)) then local value, unit = Units.parse(horizontal) - horizontal = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) + horizontal = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth, nil, nil) end end for _, side in ipairs({ "top", "right", "bottom", "left" }) do local value = spacingProps[side] if value then - if type(value) == "string" then + if type(value) == "string" or (Units._Calc and Units._Calc.isCalc(value)) then local numValue, unit = Units.parse(value) local parentSize = (side == "top" or side == "bottom") and parentHeight or parentWidth - result[side] = Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize) + result[side] = Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize, nil, nil) else result[side] = value end @@ -205,10 +225,15 @@ function Units.resolveSpacing(spacingProps, parentWidth, parentHeight) end --- Validate a unit string format ---- Checks if the string can be successfully parsed as a valid unit ----@param unitStr string The unit string to validate (e.g., "50px", "10%") +--- Checks if the string can be successfully parsed as a valid unit or calc expression +---@param unitStr string|table The unit string to validate (e.g., "50px", "10%") or calc object ---@return boolean isValid True if the unit string is valid, false otherwise function Units.isValid(unitStr) + -- Check if it's a calc expression + if Units._Calc and Units._Calc.isCalc(unitStr) then + return true + end + if type(unitStr) ~= "string" then return false end diff --git a/scripts/create-profile-packages.sh b/scripts/create-profile-packages.sh index b5d2371..9374a93 100755 --- a/scripts/create-profile-packages.sh +++ b/scripts/create-profile-packages.sh @@ -61,10 +61,10 @@ get_modules() { echo "utils.lua Units.lua Context.lua StateManager.lua ErrorHandler.lua Color.lua InputEvent.lua TextEditor.lua LayoutEngine.lua Renderer.lua EventHandler.lua ScrollManager.lua Element.lua RoundedRect.lua Grid.lua ModuleLoader.lua types.lua FFI.lua UTF8.lua Animation.lua NinePatch.lua ImageRenderer.lua ImageScaler.lua ImageCache.lua" ;; default) - echo "utils.lua Units.lua Context.lua StateManager.lua ErrorHandler.lua Color.lua InputEvent.lua TextEditor.lua LayoutEngine.lua Renderer.lua EventHandler.lua ScrollManager.lua Element.lua RoundedRect.lua Grid.lua ModuleLoader.lua types.lua FFI.lua UTF8.lua Animation.lua NinePatch.lua ImageRenderer.lua ImageScaler.lua ImageCache.lua Theme.lua Blur.lua GestureRecognizer.lua" + echo "utils.lua Units.lua Calc.lua Context.lua StateManager.lua ErrorHandler.lua Color.lua InputEvent.lua TextEditor.lua LayoutEngine.lua Renderer.lua EventHandler.lua ScrollManager.lua Element.lua RoundedRect.lua Grid.lua ModuleLoader.lua types.lua FFI.lua UTF8.lua Animation.lua NinePatch.lua ImageRenderer.lua ImageScaler.lua ImageCache.lua Theme.lua Blur.lua GestureRecognizer.lua" ;; full) - echo "utils.lua Units.lua Context.lua StateManager.lua ErrorHandler.lua Color.lua InputEvent.lua TextEditor.lua LayoutEngine.lua Renderer.lua EventHandler.lua ScrollManager.lua Element.lua RoundedRect.lua Grid.lua ModuleLoader.lua types.lua FFI.lua UTF8.lua Animation.lua NinePatch.lua ImageRenderer.lua ImageScaler.lua ImageCache.lua Theme.lua Blur.lua GestureRecognizer.lua Performance.lua MemoryScanner.lua" + echo "utils.lua Units.lua Calc.lua Context.lua StateManager.lua ErrorHandler.lua Color.lua InputEvent.lua TextEditor.lua LayoutEngine.lua Renderer.lua EventHandler.lua ScrollManager.lua Element.lua RoundedRect.lua Grid.lua ModuleLoader.lua types.lua FFI.lua UTF8.lua Animation.lua NinePatch.lua ImageRenderer.lua ImageScaler.lua ImageCache.lua Theme.lua Blur.lua GestureRecognizer.lua Performance.lua MemoryScanner.lua" ;; esac } diff --git a/testing/__tests__/animation_test.lua b/testing/__tests__/animation_test.lua index a9bc069..87bc6cb 100644 --- a/testing/__tests__/animation_test.lua +++ b/testing/__tests__/animation_test.lua @@ -11,6 +11,15 @@ local ErrorHandler = require("modules.ErrorHandler") -- Initialize ErrorHandler ErrorHandler.init({}) +-- Setup package loader to map FlexLove.modules.X to modules/X +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + -- Load FlexLove which properly initializes all dependencies local FlexLove = require("FlexLove") diff --git a/testing/__tests__/calc_test.lua b/testing/__tests__/calc_test.lua new file mode 100644 index 0000000..eb7e571 --- /dev/null +++ b/testing/__tests__/calc_test.lua @@ -0,0 +1,232 @@ +---@diagnostic disable: undefined-global, undefined-field +local lu = require("testing.luaunit") + +-- Mock love globals for testing environment +_G.love = _G.love or {} +_G.love.graphics = _G.love.graphics or {} +_G.love.graphics.getDimensions = function() + return 1920, 1080 +end +_G.love.window = _G.love.window or {} +_G.love.window.getMode = function() + return 1920, 1080 +end + +-- Load Calc module directly +local Calc = require("modules.Calc") +local ErrorHandler = require("modules.ErrorHandler") + +-- Initialize with error handler +ErrorHandler.init({}) +Calc.init({ ErrorHandler = ErrorHandler }) + +---@class TestCalc +TestCalc = {} + +function TestCalc:setUp() + -- Fresh initialization for each test +end + +function TestCalc:tearDown() + -- Cleanup after each test +end + +--- Test basic arithmetic: addition +function TestCalc:testBasicAddition() + local calcObj = Calc.new("100px + 50px") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 150) +end + +--- Test basic arithmetic: subtraction +function TestCalc:testBasicSubtraction() + local calcObj = Calc.new("100px - 30px") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 70) +end + +--- Test basic arithmetic: multiplication +function TestCalc:testBasicMultiplication() + local calcObj = Calc.new("10px * 5") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 50) +end + +--- Test basic arithmetic: division +function TestCalc:testBasicDivision() + local calcObj = Calc.new("100px / 4") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 25) +end + +--- Test negative numbers +function TestCalc:testNegativeNumbers() + local calcObj = Calc.new("-50px + 100px") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 50) +end + +--- Test decimal numbers +function TestCalc:testDecimalNumbers() + local calcObj = Calc.new("10.5px + 5.5px") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 16) +end + +--- Test percentage units +function TestCalc:testPercentageUnits() + local calcObj = Calc.new("50% + 25%") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, 1000, nil, nil) -- parent size = 1000 + lu.assertEquals(result, 750) -- 50% of 1000 + 25% of 1000 = 500 + 250 +end + +--- Test viewport width units (vw) +function TestCalc:testViewportWidthUnits() + local calcObj = Calc.new("50vw - 10vw") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 768) -- 40% of 1920 = 768 +end + +--- Test viewport height units (vh) +function TestCalc:testViewportHeightUnits() + local calcObj = Calc.new("50vh + 10vh") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 648) -- 60% of 1080 = 648 +end + +--- Test mixed units +function TestCalc:testMixedUnits() + local calcObj = Calc.new("50% - 10vw") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, 1000, nil, nil) + lu.assertEquals(result, 308) -- 50% of 1000 - 10% of 1920 = 500 - 192 = 308 +end + +--- Test complex expression with multiple operations +function TestCalc:testComplexExpression() + local calcObj = Calc.new("100px + 50px - 20px") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 130) +end + +--- Test parentheses for precedence +function TestCalc:testParentheses() + local calcObj = Calc.new("(100px + 50px) * 2") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 300) +end + +--- Test nested parentheses +function TestCalc:testNestedParentheses() + local calcObj = Calc.new("((100px + 50px) / 3) * 2") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 100) -- (150 / 3) * 2 = 50 * 2 = 100 +end + +--- Test operator precedence (multiplication before addition) +function TestCalc:testOperatorPrecedence() + local calcObj = Calc.new("100px + 50px * 2") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 200) -- 100 + (50 * 2) = 100 + 100 = 200 +end + +--- Test centering use case (50% - 10vw) +function TestCalc:testCenteringUseCase() + local calcObj = Calc.new("50% - 10vw") + lu.assertTrue(Calc.isCalc(calcObj)) + -- Assuming element width is 20vw (384px) and parent width is 1920px + -- 50% of parent - 10vw should center it + local result = Calc.resolve(calcObj, 1920, 1080, 1920, nil, nil) + lu.assertEquals(result, 768) -- 50% of 1920 - 10% of 1920 = 960 - 192 = 768 +end + +--- Test element width units (ew) +function TestCalc:testElementWidthUnits() + local calcObj = Calc.new("100ew - 20ew") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, 500, nil) -- element width = 500 + lu.assertEquals(result, 400) -- 80% of 500 = 400 +end + +--- Test element height units (eh) +function TestCalc:testElementHeightUnits() + local calcObj = Calc.new("50eh + 25eh") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, 300) -- element height = 300 + lu.assertEquals(result, 225) -- 75% of 300 = 225 +end + +--- Test whitespace handling +function TestCalc:testWhitespaceHandling() + local calcObj = Calc.new(" 100px + 50px ") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 150) +end + +--- Test zero value +function TestCalc:testZeroValue() + local calcObj = Calc.new("100px - 100px") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 0) +end + +--- Test single value (no operation) +function TestCalc:testSingleValue() + local calcObj = Calc.new("100px") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 100) +end + +--- Test isCalc function with non-calc values +function TestCalc:testIsCalcWithNonCalcValues() + lu.assertFalse(Calc.isCalc("100px")) + lu.assertFalse(Calc.isCalc(100)) + lu.assertFalse(Calc.isCalc(nil)) + lu.assertFalse(Calc.isCalc({})) +end + +--- Test division by zero error handling +function TestCalc:testDivisionByZeroHandling() + local calcObj = Calc.new("100px / 0") + lu.assertTrue(Calc.isCalc(calcObj)) + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 0) -- Should return 0 on division by zero error +end + +--- Test invalid expression error handling +function TestCalc:testInvalidExpressionHandling() + local calcObj = Calc.new("100px +") -- Incomplete expression + lu.assertTrue(Calc.isCalc(calcObj)) + -- Should return 0 for invalid expressions + local result = Calc.resolve(calcObj, 1920, 1080, nil, nil, nil) + lu.assertEquals(result, 0) +end + +--- Test complex real-world centering scenario +function TestCalc:testRealWorldCentering() + -- Button with 20vw width, centered at 50% - 10vw + local xCalc = Calc.new("50% - 10vw") + local result = Calc.resolve(xCalc, 1920, 1080, 1920, nil, nil) + -- Expected: 50% of 1920 - 10% of 1920 = 960 - 192 = 768 + lu.assertEquals(result, 768) +end + +if not _G.RUNNING_ALL_TESTS then + os.exit(lu.LuaUnit.run()) +end diff --git a/testing/__tests__/critical_failures_test.lua b/testing/__tests__/critical_failures_test.lua index 02d510a..d76a013 100644 --- a/testing/__tests__/critical_failures_test.lua +++ b/testing/__tests__/critical_failures_test.lua @@ -5,6 +5,16 @@ -- 3. Unsafe input access (nil dereference, division by zero, etc.) package.path = package.path .. ";./?.lua;./modules/?.lua" + +-- Add custom package searcher to handle FlexLove.modules.X imports +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + require("testing.loveStub") local luaunit = require("testing.luaunit") local FlexLove = require("FlexLove") diff --git a/testing/__tests__/element_test.lua b/testing/__tests__/element_test.lua index fc3626e..9ea1063 100644 --- a/testing/__tests__/element_test.lua +++ b/testing/__tests__/element_test.lua @@ -12,6 +12,15 @@ local ErrorHandler = require("modules.ErrorHandler") -- Initialize ErrorHandler ErrorHandler.init({}) +-- Setup package loader to map FlexLove.modules.X to modules/X +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + -- Load FlexLove which properly initializes all dependencies local FlexLove = require("FlexLove") local Element = require("modules.Element") diff --git a/testing/__tests__/flexlove_test.lua b/testing/__tests__/flexlove_test.lua index 37cf4b6..c869d92 100644 --- a/testing/__tests__/flexlove_test.lua +++ b/testing/__tests__/flexlove_test.lua @@ -1,3 +1,12 @@ +-- Add custom package searcher to handle FlexLove.modules.X imports +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + local luaunit = require("testing.luaunit") local ErrorHandler = require("modules.ErrorHandler") require("testing.loveStub") diff --git a/testing/__tests__/grid_test.lua b/testing/__tests__/grid_test.lua index 1eb84e9..25cdda2 100644 --- a/testing/__tests__/grid_test.lua +++ b/testing/__tests__/grid_test.lua @@ -4,6 +4,16 @@ package.path = package.path .. ";./?.lua;./modules/?.lua" require("testing.loveStub") local luaunit = require("testing.luaunit") + +-- Setup package loader to map FlexLove.modules.X to modules/X +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + local FlexLove = require("FlexLove") TestGridLayout = {} diff --git a/testing/__tests__/image_renderer_test.lua b/testing/__tests__/image_renderer_test.lua index 5d3fd6e..0ed6854 100644 --- a/testing/__tests__/image_renderer_test.lua +++ b/testing/__tests__/image_renderer_test.lua @@ -518,6 +518,13 @@ function TestImageRendererElementIntegration:setUp() local Renderer = require("modules.Renderer") local EventHandler = require("modules.EventHandler") local ImageCache = require("modules.ImageCache") + local Context = require("modules.Context") + local StateManager = require("modules.StateManager") + local InputEvent = require("modules.InputEvent") + local Theme = require("modules.Theme") + local TextEditor = require("modules.TextEditor") + local ScrollManager = require("modules.ScrollManager") + local RoundedRect = require("modules.RoundedRect") self.deps = { utils = utils, @@ -529,7 +536,18 @@ function TestImageRendererElementIntegration:setUp() ImageCache = ImageCache, ImageRenderer = ImageRenderer, ErrorHandler = ErrorHandler, + Context = Context, + StateManager = StateManager, + InputEvent = InputEvent, + Theme = Theme, + TextEditor = TextEditor, + ScrollManager = ScrollManager, + RoundedRect = RoundedRect, } + + -- Initialize Element with dependencies + Element.init(self.deps) + self.Element = Element end diff --git a/testing/__tests__/image_scaler_test.lua b/testing/__tests__/image_scaler_test.lua index fae8144..14faeef 100644 --- a/testing/__tests__/image_scaler_test.lua +++ b/testing/__tests__/image_scaler_test.lua @@ -6,10 +6,9 @@ ErrorHandler.init({}) require("testing.loveStub") local ImageScaler = require("modules.ImageScaler") -local ErrorHandler = require("modules.ErrorHandler") --- Initialize ErrorHandler -ErrorHandler.init({}) +-- Initialize ImageScaler with ErrorHandler +ImageScaler.init({ ErrorHandler = ErrorHandler }) TestImageScaler = {} diff --git a/testing/__tests__/layout_engine_test.lua b/testing/__tests__/layout_engine_test.lua index 16d7470..ba429a4 100644 --- a/testing/__tests__/layout_engine_test.lua +++ b/testing/__tests__/layout_engine_test.lua @@ -11,9 +11,19 @@ local luaunit = require("testing.luaunit") local LayoutEngine = require("modules.LayoutEngine") local Units = require("modules.Units") local utils = require("modules.utils") -local FlexLove = require("FlexLove") local ErrorHandler = require("modules.ErrorHandler") local Animation = require("modules.Animation") + +-- Setup package loader to map FlexLove.modules.X to modules/X +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + +local FlexLove = require("FlexLove") local Transform = Animation.Transform -- ============================================================================ diff --git a/testing/__tests__/performance_test.lua b/testing/__tests__/performance_test.lua index dffd8f3..48c94d2 100644 --- a/testing/__tests__/performance_test.lua +++ b/testing/__tests__/performance_test.lua @@ -7,6 +7,15 @@ local loveStub = require("testing.loveStub") -- Set up stub before requiring modules _G.love = loveStub +-- Setup package loader to map FlexLove.modules.X to modules/X +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + local FlexLove = require("FlexLove") local Performance = require("modules.Performance") local Element = require('modules.Element') diff --git a/testing/__tests__/renderer_test.lua b/testing/__tests__/renderer_test.lua index 040c9ba..df5de57 100644 --- a/testing/__tests__/renderer_test.lua +++ b/testing/__tests__/renderer_test.lua @@ -11,6 +11,16 @@ local Theme = require("modules.Theme") local Blur = require("modules.Blur") local utils = require("modules.utils") local ErrorHandler = require("modules.ErrorHandler") + +-- Setup package loader to map FlexLove.modules.X to modules/X +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + local FlexLove = require("FlexLove") -- Initialize ErrorHandler diff --git a/testing/__tests__/touch_events_test.lua b/testing/__tests__/touch_events_test.lua index dae495c..412b07a 100644 --- a/testing/__tests__/touch_events_test.lua +++ b/testing/__tests__/touch_events_test.lua @@ -1,5 +1,14 @@ package.path = package.path .. ";./?.lua;./modules/?.lua" +-- Add custom package searcher to handle FlexLove.modules.X imports +local originalSearchers = package.searchers or package.loaders +table.insert(originalSearchers, 2, function(modname) + if modname:match("^FlexLove%.modules%.") then + local moduleName = modname:gsub("^FlexLove%.modules%.", "") + return function() return require("modules." .. moduleName) end + end +end) + require("testing.loveStub") local lu = require("testing.luaunit") diff --git a/testing/__tests__/units_test.lua b/testing/__tests__/units_test.lua index c9f6c20..b7e2db5 100644 --- a/testing/__tests__/units_test.lua +++ b/testing/__tests__/units_test.lua @@ -9,9 +9,17 @@ require("testing.loveStub") local luaunit = require("testing.luaunit") local Units = require("modules.Units") local Context = require("modules.Context") +local ErrorHandler = require("modules.ErrorHandler") +local Calc = require("modules.Calc") --- Initialize Units module with Context -Units.init({ Context = Context }) +-- Initialize ErrorHandler +ErrorHandler.init({}) + +-- Initialize Calc +Calc.init({ ErrorHandler = ErrorHandler }) + +-- Initialize Units module with dependencies +Units.init({ Context = Context, ErrorHandler = ErrorHandler, Calc = Calc }) -- Mock viewport dimensions for consistent tests local MOCK_VIEWPORT_WIDTH = 1920 diff --git a/testing/runAll.lua b/testing/runAll.lua index b4fe94d..3ce8375 100644 --- a/testing/runAll.lua +++ b/testing/runAll.lua @@ -56,6 +56,7 @@ local testFiles = { "testing/__tests__/theme_test.lua", "testing/__tests__/units_test.lua", "testing/__tests__/utils_test.lua", + "testing/__tests__/calc_test.lua", -- Feature/Integration tests "testing/__tests__/critical_failures_test.lua", "testing/__tests__/flexlove_test.lua",