calc module

This commit is contained in:
Michael Freno
2025-12-07 00:56:55 -05:00
parent f532837cf3
commit 502eeb1e11
23 changed files with 870 additions and 68 deletions

404
modules/Calc.lua Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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