diff --git a/FlexLove.lua b/FlexLove.lua index db1a0d8..7055051 100644 --- a/FlexLove.lua +++ b/FlexLove.lua @@ -6,304 +6,20 @@ For full documentation, see README.md ]] -- ==================== --- fast Gaussian blur +-- Module Imports -- ==================== - -local Blur = {} - --- Canvas cache to avoid recreating canvases every frame -local canvasCache = {} -local MAX_CACHE_SIZE = 20 - ---- Build Gaussian blur shader with given parameters ----@param taps number -- Number of samples (must be odd, >= 3) ----@param offset number -- Offset multiplier for sampling ----@param offset_type string -- "weighted" or "center" ----@param sigma number -- Gaussian sigma value ----@return love.Shader -local function buildShader(taps, offset, offset_type, sigma) - taps = math.floor(taps) - sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6 - sigma = math.max(sigma, 1) - - local steps = (taps + 1) / 2 - - -- Calculate gaussian function - local g_offsets = {} - local g_weights = {} - for i = 1, steps, 1 do - g_offsets[i] = offset * (i - 1) - g_weights[i] = math.exp(-0.5 * (g_offsets[i] - 0) ^ 2 * 1 / sigma ^ 2) - end - - -- Calculate offsets and weights for sub-pixel samples - local offsets = {} - local weights = {} - for i = #g_weights, 2, -2 do - local oA, oB = g_offsets[i], g_offsets[i - 1] - local wA, wB = g_weights[i], g_weights[i - 1] - wB = oB == 0 and wB / 2 or wB - local weight = wA + wB - offsets[#offsets + 1] = offset_type == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight - weights[#weights + 1] = weight - end - - local code = { [[ - extern vec2 direction; - vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]] } - - local norm = 0 - if #g_weights % 2 == 0 then - code[#code + 1] = "vec4 c = vec4( 0.0 );" - else - local weight = g_weights[1] - norm = norm + weight - code[#code + 1] = ("vec4 c = %f * texture2D(tex, tc);"):format(weight) - end - - local tmpl = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n" - for i = 1, #offsets, 1 do - local offset = offsets[i] - local weight = weights[i] - norm = norm + weight * 2 - code[#code + 1] = tmpl:format(weight, offset, offset) - end - code[#code + 1] = ("return c * vec4(%f) * color; }"):format(1 / norm) - - local shader = table.concat(code) - return love.graphics.newShader(shader) -end - ---- Get or create a canvas from cache ----@param width number ----@param height number ----@return love.Canvas -local function getCanvas(width, height) - local key = string.format("%dx%d", width, height) - - if not canvasCache[key] then - canvasCache[key] = {} - end - - local cache = canvasCache[key] - - -- Try to reuse existing canvas - for i, canvas in ipairs(cache) do - if not canvas.inUse then - canvas.inUse = true - return canvas.canvas - end - end - - -- Create new canvas if none available - local canvas = love.graphics.newCanvas(width, height) - table.insert(cache, { canvas = canvas, inUse = true }) - - -- Limit cache size - if #cache > MAX_CACHE_SIZE then - table.remove(cache, 1) - end - - return canvas -end - ---- Release a canvas back to the cache ----@param canvas love.Canvas -local function releaseCanvas(canvas) - for _, sizeCache in pairs(canvasCache) do - for _, entry in ipairs(sizeCache) do - if entry.canvas == canvas then - entry.inUse = false - return - end - end - end -end - ---- Create a blur effect instance ----@param quality number -- Quality level (1-10, higher = better quality but slower) ----@return table -- Blur effect instance -function Blur.new(quality) - quality = math.max(1, math.min(10, quality or 5)) - - -- Map quality to shader parameters - -- Quality 1: 3 taps (fastest, lowest quality) - -- Quality 5: 7 taps (balanced) - -- Quality 10: 15 taps (slowest, highest quality) - local taps = 3 + (quality - 1) * 1.5 - taps = math.floor(taps) - if taps % 2 == 0 then - taps = taps + 1 -- Ensure odd number - end - - local offset = 1.0 - local offset_type = "weighted" - local sigma = -1 - - local shader = buildShader(taps, offset, offset_type, sigma) - - local instance = { - shader = shader, - quality = quality, - taps = taps, - } - - return instance -end - ---- Apply blur to a region of the screen ----@param blurInstance table -- Blur effect instance from Blur.new() ----@param intensity number -- Blur intensity (0-100) ----@param x number -- X position ----@param y number -- Y position ----@param width number -- Width ----@param height number -- Height ----@param drawFunc function -- Function to draw content to be blurred -function Blur.applyToRegion(blurInstance, intensity, x, y, width, height, drawFunc) - if intensity <= 0 or width <= 0 or height <= 0 then - -- No blur, just draw normally - drawFunc() - return - end - - -- Clamp intensity - intensity = math.max(0, math.min(100, intensity)) - - -- Calculate blur passes based on intensity - -- Intensity 0-100 maps to 0-5 passes - local passes = math.ceil(intensity / 20) - passes = math.max(1, math.min(5, passes)) - - -- Get canvases for ping-pong rendering - local canvas1 = getCanvas(width, height) - local canvas2 = getCanvas(width, height) - - -- Save graphics state - local prevCanvas = love.graphics.getCanvas() - local prevShader = love.graphics.getShader() - local prevColor = { love.graphics.getColor() } - local prevBlendMode = love.graphics.getBlendMode() - - -- Render content to first canvas - love.graphics.setCanvas(canvas1) - love.graphics.clear() - love.graphics.push() - love.graphics.origin() - love.graphics.translate(-x, -y) - drawFunc() - love.graphics.pop() - - -- Apply blur passes - love.graphics.setShader(blurInstance.shader) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.setBlendMode("alpha", "premultiplied") - - for i = 1, passes do - -- Horizontal pass - love.graphics.setCanvas(canvas2) - love.graphics.clear() - blurInstance.shader:send("direction", { 1 / width, 0 }) - love.graphics.draw(canvas1, 0, 0) - - -- Vertical pass - love.graphics.setCanvas(canvas1) - love.graphics.clear() - blurInstance.shader:send("direction", { 0, 1 / height }) - love.graphics.draw(canvas2, 0, 0) - end - - -- Draw blurred result to screen - love.graphics.setCanvas(prevCanvas) - love.graphics.setShader() - love.graphics.setBlendMode(prevBlendMode) - love.graphics.draw(canvas1, x, y) - - -- Restore graphics state - love.graphics.setShader(prevShader) - love.graphics.setColor(unpack(prevColor)) - - -- Release canvases back to cache - releaseCanvas(canvas1) - releaseCanvas(canvas2) -end - ---- Apply backdrop blur effect (blur content behind a region) ----@param blurInstance table -- Blur effect instance from Blur.new() ----@param intensity number -- Blur intensity (0-100) ----@param x number -- X position ----@param y number -- Y position ----@param width number -- Width ----@param height number -- Height ----@param backdropCanvas love.Canvas -- Canvas containing the backdrop content -function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdropCanvas) - if intensity <= 0 or width <= 0 or height <= 0 then - return - end - - -- Clamp intensity - intensity = math.max(0, math.min(100, intensity)) - - -- Calculate blur passes based on intensity - local passes = math.ceil(intensity / 20) - passes = math.max(1, math.min(5, passes)) - - -- Get canvases for ping-pong rendering - local canvas1 = getCanvas(width, height) - local canvas2 = getCanvas(width, height) - - -- Save graphics state - local prevCanvas = love.graphics.getCanvas() - local prevShader = love.graphics.getShader() - local prevColor = { love.graphics.getColor() } - local prevBlendMode = love.graphics.getBlendMode() - - -- Extract the region from backdrop - love.graphics.setCanvas(canvas1) - love.graphics.clear() - love.graphics.setColor(1, 1, 1, 1) - love.graphics.setBlendMode("alpha", "premultiplied") - - -- Create a quad for the region - local backdropWidth, backdropHeight = backdropCanvas:getDimensions() - local quad = love.graphics.newQuad(x, y, width, height, backdropWidth, backdropHeight) - love.graphics.draw(backdropCanvas, quad, 0, 0) - - -- Apply blur passes - love.graphics.setShader(blurInstance.shader) - - for i = 1, passes do - -- Horizontal pass - love.graphics.setCanvas(canvas2) - love.graphics.clear() - blurInstance.shader:send("direction", { 1 / width, 0 }) - love.graphics.draw(canvas1, 0, 0) - - -- Vertical pass - love.graphics.setCanvas(canvas1) - love.graphics.clear() - blurInstance.shader:send("direction", { 0, 1 / height }) - love.graphics.draw(canvas2, 0, 0) - end - - -- Draw blurred result to screen - love.graphics.setCanvas(prevCanvas) - love.graphics.setShader() - love.graphics.setBlendMode(prevBlendMode) - love.graphics.draw(canvas1, x, y) - - -- Restore graphics state - love.graphics.setShader(prevShader) - love.graphics.setColor(unpack(prevColor)) - - -- Release canvases back to cache - releaseCanvas(canvas1) - releaseCanvas(canvas2) -end - ---- Clear canvas cache (call on window resize) -function Blur.clearCache() - canvasCache = {} -end +local Blur = require("flexlove.Blur") +local Color = require("flexlove.Color") +local ImageDataReader = require("flexlove.ImageDataReader") +local NinePatchParser = require("flexlove.NinePatchParser") +local ImageScaler = require("flexlove.ImageScaler") +local ImageCache = require("flexlove.ImageCache") +local ImageRenderer = require("flexlove.ImageRenderer") +local Theme = require("flexlove.Theme") +local RoundedRect = require("flexlove.RoundedRect") +local NineSlice = require("flexlove.NineSlice") +local enums = require("flexlove.types") +local constants = require("flexlove.constants") -- ==================== -- Error Handling Utilities @@ -317,7 +33,11 @@ local function formatError(module, message) return string.format("[FlexLove.%s] %s", module, message) end ---- Top level GUI manager +-- ==================== +-- Top level GUI manager +-- ==================== + +--- ---@class Gui ---@field topElements table ---@field baseScale {width:number, height:number}? @@ -332,1657 +52,6 @@ local Gui = { _focusedElement = nil, -- Currently focused element for keyboard input } --- ==================== --- Color System --- ==================== - ---- Utility class for color handling ----@class Color ----@field r number -- Red component (0-1) ----@field g number -- Green component (0-1) ----@field b number -- Blue component (0-1) ----@field a number -- Alpha component (0-1) -local Color = {} -Color.__index = Color - ---- Create a new color instance ----@param r number? -- Default: 0 ----@param g number? -- Default: 0 ----@param b number? -- Default: 0 ----@param a number? -- Default: 1 ----@return Color -function Color.new(r, g, b, a) - local self = setmetatable({}, Color) - self.r = r or 0 - self.g = g or 0 - self.b = b or 0 - self.a = a or 1 - return self -end - ----@return number r, number g, number b, number a -function Color:toRGBA() - return self.r, self.g, self.b, self.a -end - ---- Convert hex string to color ---- Supports both 6-digit (#RRGGBB) and 8-digit (#RRGGBBAA) hex formats ----@param hexWithTag string -- e.g. "#RRGGBB" or "#RRGGBBAA" ----@return Color ----@throws Error if hex string format is invalid -function Color.fromHex(hexWithTag) - local hex = hexWithTag:gsub("#", "") - if #hex == 6 then - local r = tonumber("0x" .. hex:sub(1, 2)) - local g = tonumber("0x" .. hex:sub(3, 4)) - local b = tonumber("0x" .. hex:sub(5, 6)) - if not r or not g or not b then - error(formatError("Color", string.format("Invalid hex string format: '%s'. Contains invalid hex digits", hexWithTag))) - end - return Color.new(r, g, b, 1) - elseif #hex == 8 then - local r = tonumber("0x" .. hex:sub(1, 2)) - local g = tonumber("0x" .. hex:sub(3, 4)) - local b = tonumber("0x" .. hex:sub(5, 6)) - local a = tonumber("0x" .. hex:sub(7, 8)) - if not r or not g or not b or not a then - error(formatError("Color", string.format("Invalid hex string format: '%s'. Contains invalid hex digits", hexWithTag))) - end - return Color.new(r, g, b, a / 255) - else - error(formatError("Color", string.format("Invalid hex string format: '%s'. Expected #RRGGBB or #RRGGBBAA", hexWithTag))) - end -end - --- ==================== --- ImageDataReader --- ==================== - -local ImageDataReader = {} - ---- Load ImageData from a file path ----@param imagePath string ----@return love.ImageData -function ImageDataReader.loadImageData(imagePath) - if not imagePath then - error(formatError("ImageDataReader", "Image path cannot be nil")) - end - - local success, result = pcall(function() - return love.image.newImageData(imagePath) - end) - - if not success then - error(formatError("ImageDataReader", "Failed to load image data from '" .. imagePath .. "': " .. tostring(result))) - end - - return result -end - ---- Extract all pixels from a specific row ----@param imageData love.ImageData ----@param rowIndex number -- 0-based row index ----@return table -- Array of {r, g, b, a} values (0-255 range) -function ImageDataReader.getRow(imageData, rowIndex) - if not imageData then - error(formatError("ImageDataReader", "ImageData cannot be nil")) - end - - local width = imageData:getWidth() - local height = imageData:getHeight() - - if rowIndex < 0 or rowIndex >= height then - error(formatError("ImageDataReader", string.format("Row index %d out of bounds (height: %d)", rowIndex, height))) - end - - local pixels = {} - for x = 0, width - 1 do - local r, g, b, a = imageData:getPixel(x, rowIndex) - table.insert(pixels, { - r = math.floor(r * 255 + 0.5), - g = math.floor(g * 255 + 0.5), - b = math.floor(b * 255 + 0.5), - a = math.floor(a * 255 + 0.5), - }) - end - - return pixels -end - ---- Extract all pixels from a specific column ----@param imageData love.ImageData ----@param colIndex number -- 0-based column index ----@return table -- Array of {r, g, b, a} values (0-255 range) -function ImageDataReader.getColumn(imageData, colIndex) - if not imageData then - error(formatError("ImageDataReader", "ImageData cannot be nil")) - end - - local width = imageData:getWidth() - local height = imageData:getHeight() - - if colIndex < 0 or colIndex >= width then - error(formatError("ImageDataReader", string.format("Column index %d out of bounds (width: %d)", colIndex, width))) - end - - local pixels = {} - for y = 0, height - 1 do - local r, g, b, a = imageData:getPixel(colIndex, y) - table.insert(pixels, { - r = math.floor(r * 255 + 0.5), - g = math.floor(g * 255 + 0.5), - b = math.floor(b * 255 + 0.5), - a = math.floor(a * 255 + 0.5), - }) - end - - return pixels -end - ---- Check if a pixel is black with full alpha (9-patch marker) ----@param r number -- Red (0-255) ----@param g number -- Green (0-255) ----@param b number -- Blue (0-255) ----@param a number -- Alpha (0-255) ----@return boolean -function ImageDataReader.isBlackPixel(r, g, b, a) - return r == 0 and g == 0 and b == 0 and a == 255 -end - --- ==================== --- NinePatchParser --- ==================== - -local NinePatchParser = {} - ---- Find all continuous runs of black pixels in a pixel array ----@param pixels table -- Array of {r, g, b, a} pixel values ----@return table -- Array of {start, end} pairs (1-based indices, inclusive) -local function findBlackPixelRuns(pixels) - local runs = {} - local inRun = false - local runStart = nil - - for i = 1, #pixels do - local pixel = pixels[i] - local isBlack = ImageDataReader.isBlackPixel(pixel.r, pixel.g, pixel.b, pixel.a) - - if isBlack and not inRun then - -- Start of a new run - inRun = true - runStart = i - elseif not isBlack and inRun then - -- End of current run - table.insert(runs, { start = runStart, ["end"] = i - 1 }) - inRun = false - runStart = nil - end - end - - -- Handle case where run extends to end of array - if inRun then - table.insert(runs, { start = runStart, ["end"] = #pixels }) - end - - return runs -end - ---- Parse a 9-patch PNG image to extract stretch regions and content padding ----@param imagePath string -- Path to the 9-patch image file ----@return table|nil, string|nil -- Returns {insets, stretchX, stretchY} or nil, error message -function NinePatchParser.parse(imagePath) - if not imagePath then - return nil, "Image path cannot be nil" - end - - local success, imageData = pcall(function() - return ImageDataReader.loadImageData(imagePath) - end) - - if not success then - return nil, "Failed to load image data: " .. tostring(imageData) - end - - local width = imageData:getWidth() - local height = imageData:getHeight() - - -- Validate minimum size (must be at least 3x3 with 1px border) - if width < 3 or height < 3 then - return nil, string.format("Invalid 9-patch dimensions: %dx%d (minimum 3x3)", width, height) - end - - -- Extract border pixels (0-based indexing, but we convert to 1-based for processing) - local topBorder = ImageDataReader.getRow(imageData, 0) - local leftBorder = ImageDataReader.getColumn(imageData, 0) - local bottomBorder = ImageDataReader.getRow(imageData, height - 1) - local rightBorder = ImageDataReader.getColumn(imageData, width - 1) - - -- Remove corner pixels from borders (they're not part of the stretch/content markers) - -- Top and bottom borders: remove first and last pixel - local topStretchPixels = {} - local bottomContentPixels = {} - for i = 2, #topBorder - 1 do - table.insert(topStretchPixels, topBorder[i]) - end - for i = 2, #bottomBorder - 1 do - table.insert(bottomContentPixels, bottomBorder[i]) - end - - -- Left and right borders: remove first and last pixel - local leftStretchPixels = {} - local rightContentPixels = {} - for i = 2, #leftBorder - 1 do - table.insert(leftStretchPixels, leftBorder[i]) - end - for i = 2, #rightBorder - 1 do - table.insert(rightContentPixels, rightBorder[i]) - end - - -- Find stretch regions (top and left borders) - local stretchX = findBlackPixelRuns(topStretchPixels) - local stretchY = findBlackPixelRuns(leftStretchPixels) - - -- Find content padding regions (bottom and right borders) - local contentX = findBlackPixelRuns(bottomContentPixels) - local contentY = findBlackPixelRuns(rightContentPixels) - - -- Validate that we have at least one stretch region - if #stretchX == 0 or #stretchY == 0 then - return nil, "No stretch regions found (top or left border has no black pixels)" - end - - -- Calculate stretch insets from stretch regions (top/left guides) - -- Use the first stretch region's start and last stretch region's end - local firstStretchX = stretchX[1] - local lastStretchX = stretchX[#stretchX] - local firstStretchY = stretchY[1] - local lastStretchY = stretchY[#stretchY] - - -- Stretch insets define the 9-slice regions - local stretchLeft = firstStretchX.start - local stretchRight = #topStretchPixels - lastStretchX["end"] - local stretchTop = firstStretchY.start - local stretchBottom = #leftStretchPixels - lastStretchY["end"] - - -- Calculate content padding from content guides (bottom/right guides) - -- If content padding is defined, use it; otherwise use stretch regions - local contentLeft, contentRight, contentTop, contentBottom - - if #contentX > 0 then - contentLeft = contentX[1].start - contentRight = #topStretchPixels - contentX[#contentX]["end"] - else - contentLeft = stretchLeft - contentRight = stretchRight - end - - if #contentY > 0 then - contentTop = contentY[1].start - contentBottom = #leftStretchPixels - contentY[#contentY]["end"] - else - contentTop = stretchTop - contentBottom = stretchBottom - end - - return { - insets = { - left = stretchLeft, - top = stretchTop, - right = stretchRight, - bottom = stretchBottom, - }, - contentPadding = { - left = contentLeft, - top = contentTop, - right = contentRight, - bottom = contentBottom, - }, - stretchX = stretchX, - stretchY = stretchY, - } -end - --- ==================== --- ImageScaler --- ==================== - -local ImageScaler = {} - ---- Scale an ImageData region using nearest-neighbor sampling ---- Produces sharp, pixelated scaling - ideal for pixel art ----@param sourceImageData love.ImageData -- Source image data ----@param srcX number -- Source region X (0-based) ----@param srcY number -- Source region Y (0-based) ----@param srcW number -- Source region width ----@param srcH number -- Source region height ----@param destW number -- Destination width ----@param destH number -- Destination height ----@return love.ImageData -- Scaled image data -function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH) - if not sourceImageData then - error(formatError("ImageScaler", "Source ImageData cannot be nil")) - end - - if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then - error(formatError("ImageScaler", "Dimensions must be positive")) - end - - -- Create destination ImageData - local destImageData = love.image.newImageData(destW, destH) - - -- Calculate scale ratios (cached outside loops for performance) - local scaleX = srcW / destW - local scaleY = srcH / destH - - -- Nearest-neighbor sampling - for destY = 0, destH - 1 do - for destX = 0, destW - 1 do - -- Calculate source pixel coordinates using floor (nearest-neighbor) - local srcPixelX = math.floor(destX * scaleX) + srcX - local srcPixelY = math.floor(destY * scaleY) + srcY - - -- Clamp to source bounds (safety check) - srcPixelX = math.min(srcPixelX, srcX + srcW - 1) - srcPixelY = math.min(srcPixelY, srcY + srcH - 1) - - -- Sample source pixel - local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY) - - -- Write to destination - destImageData:setPixel(destX, destY, r, g, b, a) - end - end - - return destImageData -end - ---- Linear interpolation helper ---- Blends between two values based on interpolation factor ----@param a number -- Start value ----@param b number -- End value ----@param t number -- Interpolation factor [0, 1] ----@return number -- Interpolated value -local function lerp(a, b, t) - return a + (b - a) * t -end - ---- Scale an ImageData region using bilinear interpolation ---- Produces smooth, filtered scaling - ideal for high-quality upscaling ----@param sourceImageData love.ImageData -- Source image data ----@param srcX number -- Source region X (0-based) ----@param srcY number -- Source region Y (0-based) ----@param srcW number -- Source region width ----@param srcH number -- Source region height ----@param destW number -- Destination width ----@param destH number -- Destination height ----@return love.ImageData -- Scaled image data -function ImageScaler.scaleBilinear(sourceImageData, srcX, srcY, srcW, srcH, destW, destH) - if not sourceImageData then - error(formatError("ImageScaler", "Source ImageData cannot be nil")) - end - - if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then - error(formatError("ImageScaler", "Dimensions must be positive")) - end - - -- Create destination ImageData - local destImageData = love.image.newImageData(destW, destH) - - -- Calculate scale ratios - local scaleX = srcW / destW - local scaleY = srcH / destH - - -- Bilinear interpolation - for destY = 0, destH - 1 do - for destX = 0, destW - 1 do - -- Calculate fractional source position - local srcXf = destX * scaleX - local srcYf = destY * scaleY - - -- Get integer coordinates for 2x2 sampling grid - local x0 = math.floor(srcXf) - local y0 = math.floor(srcYf) - local x1 = math.min(x0 + 1, srcW - 1) - local y1 = math.min(y0 + 1, srcH - 1) - - -- Get fractional parts for interpolation - local fx = srcXf - x0 - local fy = srcYf - y0 - - -- Sample 4 neighboring pixels (with source offset) - local r00, g00, b00, a00 = sourceImageData:getPixel(srcX + x0, srcY + y0) - local r10, g10, b10, a10 = sourceImageData:getPixel(srcX + x1, srcY + y0) - local r01, g01, b01, a01 = sourceImageData:getPixel(srcX + x0, srcY + y1) - local r11, g11, b11, a11 = sourceImageData:getPixel(srcX + x1, srcY + y1) - - -- Interpolate horizontally (top and bottom rows) - local rTop = lerp(r00, r10, fx) - local gTop = lerp(g00, g10, fx) - local bTop = lerp(b00, b10, fx) - local aTop = lerp(a00, a10, fx) - - local rBottom = lerp(r01, r11, fx) - local gBottom = lerp(g01, g11, fx) - local bBottom = lerp(b01, b11, fx) - local aBottom = lerp(a01, a11, fx) - - -- Interpolate vertically (final result) - local r = lerp(rTop, rBottom, fy) - local g = lerp(gTop, gBottom, fy) - local b = lerp(bTop, bBottom, fy) - local a = lerp(aTop, aBottom, fy) - - -- Write to destination - destImageData:setPixel(destX, destY, r, g, b, a) - end - end - - return destImageData -end - --- ==================== --- ImageCache --- ==================== - ----@class ImageCache ----@field _cache table -local ImageCache = {} -ImageCache._cache = {} - ---- Normalize a file path for consistent cache keys ----@param path string -- File path to normalize ----@return string -- Normalized path -local function normalizePath(path) - -- Remove leading/trailing whitespace - path = path:match("^%s*(.-)%s*$") - -- Convert backslashes to forward slashes - path = path:gsub("\\", "/") - -- Remove redundant slashes - path = path:gsub("/+", "/") - return path -end - ---- Load an image from file path with caching ---- Returns cached image if already loaded, otherwise loads and caches it ----@param imagePath string -- Path to image file ----@param loadImageData boolean? -- Optional: also load ImageData for pixel access (default: false) ----@return love.Image|nil -- Image object or nil on error ----@return string|nil -- Error message if loading failed -function ImageCache.load(imagePath, loadImageData) - if not imagePath or type(imagePath) ~= "string" or imagePath == "" then - return nil, "Invalid image path: path must be a non-empty string" - end - - local normalizedPath = normalizePath(imagePath) - - -- Check if already cached - if ImageCache._cache[normalizedPath] then - return ImageCache._cache[normalizedPath].image, nil - end - - -- Try to load the image - local success, imageOrError = pcall(love.graphics.newImage, normalizedPath) - if not success then - return nil, string.format("Failed to load image '%s': %s", imagePath, tostring(imageOrError)) - end - - local image = imageOrError - local imgData = nil - - -- Load ImageData if requested - if loadImageData then - local dataSuccess, dataOrError = pcall(love.image.newImageData, normalizedPath) - if dataSuccess then - imgData = dataOrError - end - end - - -- Cache the image - ImageCache._cache[normalizedPath] = { - image = image, - imageData = imgData, - } - - return image, nil -end - ---- Get a cached image without loading ----@param imagePath string -- Path to image file ----@return love.Image|nil -- Cached image or nil if not found -function ImageCache.get(imagePath) - if not imagePath or type(imagePath) ~= "string" then - return nil - end - - local normalizedPath = normalizePath(imagePath) - local cached = ImageCache._cache[normalizedPath] - return cached and cached.image or nil -end - ---- Get cached ImageData for an image ----@param imagePath string -- Path to image file ----@return love.ImageData|nil -- Cached ImageData or nil if not found -function ImageCache.getImageData(imagePath) - if not imagePath or type(imagePath) ~= "string" then - return nil - end - - local normalizedPath = normalizePath(imagePath) - local cached = ImageCache._cache[normalizedPath] - return cached and cached.imageData or nil -end - ---- Remove a specific image from cache ----@param imagePath string -- Path to image file to remove ----@return boolean -- True if image was removed, false if not found -function ImageCache.remove(imagePath) - if not imagePath or type(imagePath) ~= "string" then - return false - end - - local normalizedPath = normalizePath(imagePath) - if ImageCache._cache[normalizedPath] then - -- Release the image - local cached = ImageCache._cache[normalizedPath] - if cached.image then - cached.image:release() - end - if cached.imageData then - cached.imageData:release() - end - ImageCache._cache[normalizedPath] = nil - return true - end - return false -end - ---- Clear all cached images -function ImageCache.clear() - -- Release all images - for path, cached in pairs(ImageCache._cache) do - if cached.image then - cached.image:release() - end - if cached.imageData then - cached.imageData:release() - end - end - ImageCache._cache = {} -end - ---- Get cache statistics ----@return {count: number, memoryEstimate: number} -- Cache stats -function ImageCache.getStats() - local count = 0 - local memoryEstimate = 0 - - for path, cached in pairs(ImageCache._cache) do - count = count + 1 - if cached.image then - local w, h = cached.image:getDimensions() - -- Estimate: 4 bytes per pixel (RGBA) - memoryEstimate = memoryEstimate + (w * h * 4) - end - end - - return { - count = count, - memoryEstimate = memoryEstimate, - } -end - --- ==================== --- ImageRenderer --- ==================== - ----@class ImageRenderer -local ImageRenderer = {} - ---- Calculate rendering parameters for object-fit modes ---- Returns source and destination rectangles for rendering ----@param imageWidth number -- Natural width of the image ----@param imageHeight number -- Natural height of the image ----@param boundsWidth number -- Width of the bounds to fit within ----@param boundsHeight number -- Height of the bounds to fit within ----@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill") ----@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center") ----@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number} -function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition) - fitMode = fitMode or "fill" - objectPosition = objectPosition or "center center" - - -- Validate inputs - if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then - error(formatError("ImageRenderer", "Dimensions must be positive")) - end - - local result = { - sx = 0, -- Source X - sy = 0, -- Source Y - sw = imageWidth, -- Source width - sh = imageHeight, -- Source height - dx = 0, -- Destination X - dy = 0, -- Destination Y - dw = boundsWidth, -- Destination width - dh = boundsHeight, -- Destination height - scaleX = 1, -- Scale factor X - scaleY = 1, -- Scale factor Y - } - - -- Calculate based on fit mode - if fitMode == "fill" then - -- Stretch to fill bounds (may distort) - result.scaleX = boundsWidth / imageWidth - result.scaleY = boundsHeight / imageHeight - result.dw = boundsWidth - result.dh = boundsHeight - elseif fitMode == "contain" then - -- Scale to fit within bounds (preserves aspect ratio) - local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight) - result.scaleX = scale - result.scaleY = scale - result.dw = imageWidth * scale - result.dh = imageHeight * scale - - -- Apply object-position for letterbox alignment - local posX, posY = ImageRenderer._parsePosition(objectPosition) - result.dx = (boundsWidth - result.dw) * posX - result.dy = (boundsHeight - result.dh) * posY - elseif fitMode == "cover" then - -- Scale to cover bounds (preserves aspect ratio, may crop) - local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight) - result.scaleX = scale - result.scaleY = scale - - local scaledWidth = imageWidth * scale - local scaledHeight = imageHeight * scale - - -- Apply object-position for crop alignment - local posX, posY = ImageRenderer._parsePosition(objectPosition) - - -- Calculate which part of the scaled image to show - local cropX = (scaledWidth - boundsWidth) * posX - local cropY = (scaledHeight - boundsHeight) * posY - - -- Convert back to source coordinates - result.sx = cropX / scale - result.sy = cropY / scale - result.sw = boundsWidth / scale - result.sh = boundsHeight / scale - - result.dx = 0 - result.dy = 0 - result.dw = boundsWidth - result.dh = boundsHeight - elseif fitMode == "none" then - -- Use natural size (no scaling) - result.scaleX = 1 - result.scaleY = 1 - result.dw = imageWidth - result.dh = imageHeight - - -- Apply object-position - local posX, posY = ImageRenderer._parsePosition(objectPosition) - result.dx = (boundsWidth - imageWidth) * posX - result.dy = (boundsHeight - imageHeight) * posY - elseif fitMode == "scale-down" then - -- Use none or contain, whichever is smaller - if imageWidth <= boundsWidth and imageHeight <= boundsHeight then - -- Image fits naturally, use "none" - return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition) - else - -- Image too large, use "contain" - return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition) - end - else - error(formatError("ImageRenderer", string.format("Invalid fit mode: '%s'. Must be one of: fill, contain, cover, scale-down, none", tostring(fitMode)))) - end - - return result -end - ---- Parse object-position string into normalized coordinates (0-1) ---- Supports keywords (center, top, bottom, left, right) and percentages ----@param position string -- Position string like "center center", "top left", "50% 50%" ----@return number, number -- Normalized X and Y positions (0-1) -function ImageRenderer._parsePosition(position) - if not position or type(position) ~= "string" then - return 0.5, 0.5 -- Default to center - end - - -- Split into X and Y components - local parts = {} - for part in position:gmatch("%S+") do - table.insert(parts, part:lower()) - end - - -- If only one value, use it for both axes (with special handling) - if #parts == 1 then - local val = parts[1] - if val == "left" or val == "right" then - parts = { val, "center" } - elseif val == "top" or val == "bottom" then - parts = { "center", val } - else - parts = { val, val } - end - elseif #parts == 0 then - return 0.5, 0.5 -- Default to center - end - - local function parseValue(val) - -- Handle keywords - if val == "center" then - return 0.5 - elseif val == "left" or val == "top" then - return 0 - elseif val == "right" or val == "bottom" then - return 1 - end - - -- Handle percentages - local percent = val:match("^([%d%.]+)%%$") - if percent then - return tonumber(percent) / 100 - end - - -- Handle plain numbers (treat as percentage) - local num = tonumber(val) - if num then - return num / 100 - end - - -- Invalid value, default to center - return 0.5 - end - - local x = parseValue(parts[1]) - local y = parseValue(parts[2] or parts[1]) - - -- Clamp to 0-1 range - x = math.max(0, math.min(1, x)) - y = math.max(0, math.min(1, y)) - - return x, y -end - ---- Draw an image with specified object-fit 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 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) - if not image then - return -- Nothing to draw - end - - opacity = opacity or 1 - fitMode = fitMode or "fill" - objectPosition = objectPosition or "center center" - - local imgWidth, imgHeight = image:getDimensions() - local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition) - - -- Save current color - local r, g, b, a = love.graphics.getColor() - - -- Apply opacity - love.graphics.setColor(1, 1, 1, opacity) - - -- Draw image - if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then - -- Need to use a quad for cropping - local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight) - love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh) - else - -- Simple draw with scaling - love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY) - end - - -- Restore color - love.graphics.setColor(r, g, b, a) -end - --- ==================== --- Theme System --- ==================== - ----@class ThemeRegion ----@field x number -- X position in atlas ----@field y number -- Y position in atlas ----@field w number -- Width in atlas ----@field h number -- Height in atlas - ----@class ThemeComponent ----@field atlas string|love.Image? -- Optional: component-specific atlas (overrides theme atlas). Files ending in .9.png are auto-parsed ----@field insets {left:number, top:number, right:number, bottom:number}? -- Optional: 9-patch insets (auto-extracted from .9.png files or manually defined) ----@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion} ----@field stretch {horizontal:table, vertical:table} ----@field states table? ----@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: multiplier for auto-sized content dimensions ----@field scaleCorners number? -- Optional: scale multiplier for non-stretched regions (corners/edges). E.g., 2 = 2x size. Default: nil (no scaling) ----@field scalingAlgorithm "nearest"|"bilinear"? -- Optional: scaling algorithm for non-stretched regions. Default: "bilinear" ----@field _loadedAtlas string|love.Image? -- Internal: cached loaded atlas image ----@field _loadedAtlasData love.ImageData? -- Internal: cached loaded atlas ImageData for pixel access ----@field _ninePatchData {insets:table, contentPadding:table, stretchX:table, stretchY:table}? -- Internal: parsed 9-patch data with stretch regions and content padding ----@field _scaledRegionCache table? -- Internal: cache for scaled corner/edge images - ----@class FontFamily ----@field path string -- Path to the font file (relative to FlexLove or absolute) ----@field _loadedFont love.Font? -- Internal: cached loaded font - ----@class ThemeDefinition ----@field name string ----@field atlas string|love.Image? -- Optional: global atlas (can be overridden per component) ----@field components table ----@field colors table? ----@field fonts table? -- Optional: font family definitions (name -> path) ----@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions - ----@class Theme ----@field name string ----@field atlas love.Image? -- Optional: global atlas ----@field atlasData love.ImageData? ----@field components table ----@field colors table ----@field fonts table -- Font family definitions ----@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions -local Theme = {} -Theme.__index = Theme - --- Global theme registry -local themes = {} -local activeTheme = nil - ---- Auto-detect the base path where FlexLove is located ----@return string modulePath, string filesystemPath -local function getFlexLoveBasePath() - -- Get debug info to find where this file is loaded from - local info = debug.getinfo(1, "S") - if info and info.source then - local source = info.source - -- Remove leading @ if present - if source:sub(1, 1) == "@" then - source = source:sub(2) - end - - -- Extract the directory path (remove FlexLove.lua) - local filesystemPath = source:match("(.*/)") - if filesystemPath then - -- Store the original filesystem path for loading assets - local fsPath = filesystemPath - -- Remove leading ./ if present - fsPath = fsPath:gsub("^%./", "") - -- Remove trailing / - fsPath = fsPath:gsub("/$", "") - - -- Convert filesystem path to Lua module path - local modulePath = fsPath:gsub("/", ".") - - return modulePath, fsPath - end - end - - -- Fallback: try common paths - return "libs", "libs" -end - --- Store the base paths when module loads -local FLEXLOVE_BASE_PATH, FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath() - ---- Helper function to resolve image paths relative to FlexLove ----@param imagePath string ----@return string -local function resolveImagePath(imagePath) - -- If path is already absolute or starts with known LÖVE paths, use as-is - if imagePath:match("^/") or imagePath:match("^[A-Z]:") then - return imagePath - end - - -- Otherwise, make it relative to FlexLove's location - return FLEXLOVE_FILESYSTEM_PATH .. "/" .. imagePath -end - ---- Safely load an image with error handling ---- Returns both Image and ImageData to avoid deprecated getData() API ----@param imagePath string ----@return love.Image?, love.ImageData?, string? -- Returns image, imageData, or nil with error message -local function safeLoadImage(imagePath) - local success, imageData = pcall(function() - return love.image.newImageData(imagePath) - end) - - if not success then - local errorMsg = string.format("[FlexLove] Failed to load image data: %s - %s", imagePath, tostring(imageData)) - print(errorMsg) - return nil, nil, errorMsg - end - - local imageSuccess, image = pcall(function() - return love.graphics.newImage(imageData) - end) - - if imageSuccess then - return image, imageData, nil - else - local errorMsg = string.format("[FlexLove] Failed to create image: %s - %s", imagePath, tostring(image)) - print(errorMsg) - return nil, nil, errorMsg - end -end - ---- Validate theme definition structure ----@param definition ThemeDefinition ----@return boolean, string? -- Returns true if valid, or false with error message -local function validateThemeDefinition(definition) - if not definition then - return false, "Theme definition is nil" - end - - if type(definition) ~= "table" then - return false, "Theme definition must be a table" - end - - if not definition.name or type(definition.name) ~= "string" then - return false, "Theme must have a 'name' field (string)" - end - - if definition.components and type(definition.components) ~= "table" then - return false, "Theme 'components' must be a table" - end - - if definition.colors and type(definition.colors) ~= "table" then - return false, "Theme 'colors' must be a table" - end - - if definition.fonts and type(definition.fonts) ~= "table" then - return false, "Theme 'fonts' must be a table" - end - - return true, nil -end - -function Theme.new(definition) - -- Validate theme definition - local valid, err = validateThemeDefinition(definition) - if not valid then - error("[FlexLove] Invalid theme definition: " .. tostring(err)) - end - - local self = setmetatable({}, Theme) - self.name = definition.name - - -- Load global atlas if it's a string path - if definition.atlas then - if type(definition.atlas) == "string" then - local resolvedPath = resolveImagePath(definition.atlas) - local image, imageData, loaderr = safeLoadImage(resolvedPath) - if image then - self.atlas = image - self.atlasData = imageData - else - print("[FlexLove] Warning: Failed to load global atlas for theme '" .. definition.name .. "'" .. "(" .. loaderr .. ")") - end - else - self.atlas = definition.atlas - end - end - - self.components = definition.components or {} - self.colors = definition.colors or {} - self.fonts = definition.fonts or {} - self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil - - -- Helper function to strip 1-pixel guide border from 9-patch ImageData - ---@param sourceImageData love.ImageData - ---@return love.ImageData -- New ImageData without guide border - local function stripNinePatchBorder(sourceImageData) - local srcWidth = sourceImageData:getWidth() - local srcHeight = sourceImageData:getHeight() - - -- Content dimensions (excluding 1px border on all sides) - local contentWidth = srcWidth - 2 - local contentHeight = srcHeight - 2 - - if contentWidth <= 0 or contentHeight <= 0 then - error(formatError("NinePatch", "Image too small to strip border")) - end - - -- Create new ImageData for content only - local strippedImageData = love.image.newImageData(contentWidth, contentHeight) - - -- Copy pixels from source (1,1) to (width-2, height-2) - for y = 0, contentHeight - 1 do - for x = 0, contentWidth - 1 do - local r, g, b, a = sourceImageData:getPixel(x + 1, y + 1) - strippedImageData:setPixel(x, y, r, g, b, a) - end - end - - return strippedImageData - end - - -- Helper function to load atlas with 9-patch support - local function loadAtlasWithNinePatch(comp, atlasPath, errorContext) - ---@diagnostic disable-next-line - local resolvedPath = resolveImagePath(atlasPath) - ---@diagnostic disable-next-line - local is9Patch = not comp.insets and atlasPath:match("%.9%.png$") - - if is9Patch then - local parseResult, parseErr = NinePatchParser.parse(resolvedPath) - if parseResult then - comp.insets = parseResult.insets - comp._ninePatchData = parseResult - else - print("[FlexLove] Warning: Failed to parse 9-patch " .. errorContext .. ": " .. tostring(parseErr)) - end - end - - local image, imageData, loaderr = safeLoadImage(resolvedPath) - if image then - -- Strip guide border for 9-patch images - if is9Patch and imageData then - local strippedImageData = stripNinePatchBorder(imageData) - local strippedImage = love.graphics.newImage(strippedImageData) - comp._loadedAtlas = strippedImage - comp._loadedAtlasData = strippedImageData - else - comp._loadedAtlas = image - comp._loadedAtlasData = imageData - end - else - print("[FlexLove] Warning: Failed to load atlas " .. errorContext .. ": " .. tostring(loaderr)) - end - end - - -- Helper function to create regions from insets - local function createRegionsFromInsets(comp, fallbackAtlas) - local atlasImage = comp._loadedAtlas or fallbackAtlas - if not atlasImage or type(atlasImage) == "string" then - return - end - - local imgWidth, imgHeight = atlasImage:getDimensions() - local left = comp.insets.left or 0 - local top = comp.insets.top or 0 - local right = comp.insets.right or 0 - local bottom = comp.insets.bottom or 0 - - -- No offsets needed - guide border has been stripped for 9-patch images - local centerWidth = imgWidth - left - right - local centerHeight = imgHeight - top - bottom - - comp.regions = { - topLeft = { x = 0, y = 0, w = left, h = top }, - topCenter = { x = left, y = 0, w = centerWidth, h = top }, - topRight = { x = left + centerWidth, y = 0, w = right, h = top }, - middleLeft = { x = 0, y = top, w = left, h = centerHeight }, - middleCenter = { x = left, y = top, w = centerWidth, h = centerHeight }, - middleRight = { x = left + centerWidth, y = top, w = right, h = centerHeight }, - bottomLeft = { x = 0, y = top + centerHeight, w = left, h = bottom }, - bottomCenter = { x = left, y = top + centerHeight, w = centerWidth, h = bottom }, - bottomRight = { x = left + centerWidth, y = top + centerHeight, w = right, h = bottom }, - } - end - - -- Load component-specific atlases and process 9-patch definitions - for componentName, component in pairs(self.components) do - if component.atlas then - if type(component.atlas) == "string" then - loadAtlasWithNinePatch(component, component.atlas, "for component '" .. componentName .. "'") - else - -- Direct Image object (no ImageData available - scaleCorners won't work) - component._loadedAtlas = component.atlas - end - end - - if component.insets then - createRegionsFromInsets(component, self.atlas) - end - - if component.states then - for stateName, stateComponent in pairs(component.states) do - if stateComponent.atlas then - if type(stateComponent.atlas) == "string" then - loadAtlasWithNinePatch(stateComponent, stateComponent.atlas, "for state '" .. stateName .. "'") - else - -- Direct Image object (no ImageData available - scaleCorners won't work) - stateComponent._loadedAtlas = stateComponent.atlas - end - end - - if stateComponent.insets then - createRegionsFromInsets(stateComponent, component._loadedAtlas or self.atlas) - end - end - end - end - - return self -end - ---- Load a theme from a Lua file ----@param path string -- Path to theme definition file (e.g., "space" or "mytheme") ----@return Theme -function Theme.load(path) - local definition - - -- Build the theme module path relative to FlexLove - local themePath = FLEXLOVE_BASE_PATH .. ".themes." .. path - - local success, result = pcall(function() - return require(themePath) - end) - - if success then - definition = result - else - -- Fallback: try as direct path - success, result = pcall(function() - return require(path) - end) - - if success then - definition = result - else - error("Failed to load theme '" .. path .. "'\nTried: " .. themePath .. "\nError: " .. tostring(result)) - end - end - - local theme = Theme.new(definition) - -- Register theme by both its display name and load path - themes[theme.name] = theme - themes[path] = theme - - return theme -end - ---- Set the active theme ----@param themeOrName Theme|string -function Theme.setActive(themeOrName) - if type(themeOrName) == "string" then - -- Try to load if not already loaded - if not themes[themeOrName] then - Theme.load(themeOrName) - end - activeTheme = themes[themeOrName] - else - activeTheme = themeOrName - end - - if not activeTheme then - error("Failed to set active theme: " .. tostring(themeOrName)) - end -end - ---- Get the active theme ----@return Theme? -function Theme.getActive() - return activeTheme -end - ---- Get a component from the active theme ----@param componentName string -- Name of the component (e.g., "button", "panel") ----@param state string? -- Optional state (e.g., "hover", "pressed", "disabled") ----@return ThemeComponent? -- Returns component or nil if not found -function Theme.getComponent(componentName, state) - if not activeTheme then - return nil - end - - local component = activeTheme.components[componentName] - if not component then - return nil - end - - -- Check for state-specific override - if state and component.states and component.states[state] then - return component.states[state] - end - - return component -end - ---- Get a font from the active theme ----@param fontName string -- Name of the font family (e.g., "default", "heading") ----@return string? -- Returns font path or nil if not found -function Theme.getFont(fontName) - if not activeTheme then - return nil - end - - return activeTheme.fonts and activeTheme.fonts[fontName] -end - ---- Get a color from the active theme ----@param colorName string -- Name of the color (e.g., "primary", "secondary") ----@return Color? -- Returns Color instance or nil if not found -function Theme.getColor(colorName) - if not activeTheme then - return nil - end - - return activeTheme.colors and activeTheme.colors[colorName] -end - ---- Check if a theme is currently active ----@return boolean -- Returns true if a theme is active -function Theme.hasActive() - return activeTheme ~= nil -end - ---- Get all registered theme names ----@return table -- Array of theme names -function Theme.getRegisteredThemes() - local themeNames = {} - for name, _ in pairs(themes) do - table.insert(themeNames, name) - end - return themeNames -end - ---- Get all available color names from the active theme ----@return table|nil -- Array of color names, or nil if no theme active -function Theme.getColorNames() - if not activeTheme or not activeTheme.colors then - return nil - end - - local colorNames = {} - for name, _ in pairs(activeTheme.colors) do - table.insert(colorNames, name) - end - return colorNames -end - ---- Get all colors from the active theme ----@return table|nil -- Table of all colors, or nil if no theme active -function Theme.getAllColors() - if not activeTheme then - return nil - end - - return activeTheme.colors -end - ---- Get a color with a fallback if not found ----@param colorName string -- Name of the color to retrieve ----@param fallback Color|nil -- Fallback color if not found (default: white) ----@return Color -- The color or fallback -function Theme.getColorOrDefault(colorName, fallback) - local color = Theme.getColor(colorName) - if color then - return color - end - - return fallback or Color.new(1, 1, 1, 1) -end - --- ==================== --- Rounded Rectangle Helper --- ==================== - -local RoundedRect = {} - ---- Generate points for a rounded rectangle ----@param x number ----@param y number ----@param width number ----@param height number ----@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} ----@param segments number? -- Number of segments per corner arc (default: 10) ----@return table -- Array of vertices for love.graphics.polygon -function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments) - segments = segments or 10 - local points = {} - - -- Helper to add arc points - local function addArc(cx, cy, radius, startAngle, endAngle) - if radius <= 0 then - table.insert(points, cx) - table.insert(points, cy) - return - end - - for i = 0, segments do - local angle = startAngle + (endAngle - startAngle) * (i / segments) - table.insert(points, cx + math.cos(angle) * radius) - table.insert(points, cy + math.sin(angle) * radius) - end - end - - local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2) - local r2 = math.min(cornerRadius.topRight, width / 2, height / 2) - local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2) - local r4 = math.min(cornerRadius.bottomLeft, width / 2, height / 2) - - -- Top-right corner - addArc(x + width - r2, y + r2, r2, -math.pi / 2, 0) - - -- Bottom-right corner - addArc(x + width - r3, y + height - r3, r3, 0, math.pi / 2) - - -- Bottom-left corner - addArc(x + r4, y + height - r4, r4, math.pi / 2, math.pi) - - -- Top-left corner - addArc(x + r1, y + r1, r1, math.pi, math.pi * 1.5) - - return points -end - ---- Draw a filled rounded rectangle ----@param mode string -- "fill" or "line" ----@param x number ----@param y number ----@param width number ----@param height number ----@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} -function RoundedRect.draw(mode, x, y, width, height, cornerRadius) - -- Check if any corners are rounded - local hasRoundedCorners = cornerRadius.topLeft > 0 or cornerRadius.topRight > 0 or cornerRadius.bottomLeft > 0 or cornerRadius.bottomRight > 0 - - if not hasRoundedCorners then - -- No rounded corners, use regular rectangle - love.graphics.rectangle(mode, x, y, width, height) - return - end - - local points = RoundedRect.getPoints(x, y, width, height, cornerRadius) - - if mode == "fill" then - love.graphics.polygon("fill", points) - else - -- For line mode, draw the outline - love.graphics.polygon("line", points) - end -end - ---- Create a stencil function for rounded rectangle clipping ----@param x number ----@param y number ----@param width number ----@param height number ----@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} ----@return function -function RoundedRect.stencilFunction(x, y, width, height, cornerRadius) - return function() - RoundedRect.draw("fill", x, y, width, height, cornerRadius) - end -end - --- ==================== --- NineSlice Renderer --- ==================== - -local NineSlice = {} - ---- Draw a 9-patch component using Android-style rendering ---- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only ----@param component ThemeComponent ----@param atlas love.Image ----@param x number -- X position (top-left corner) ----@param y number -- Y position (top-left corner) ----@param width number -- Total width (border-box) ----@param height number -- Total height (border-box) ----@param opacity number? ----@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier) ----@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm -function NineSlice.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm) - if not component or not atlas then - return - end - - opacity = opacity or 1 - love.graphics.setColor(1, 1, 1, opacity) - - local regions = component.regions - - -- Extract border dimensions from regions (in pixels) - local left = regions.topLeft.w - local right = regions.topRight.w - local top = regions.topLeft.h - local bottom = regions.bottomLeft.h - local centerW = regions.middleCenter.w - local centerH = regions.middleCenter.h - - -- Calculate content area (space remaining after borders) - local contentWidth = width - left - right - local contentHeight = height - top - bottom - - -- Clamp to prevent negative dimensions - contentWidth = math.max(0, contentWidth) - contentHeight = math.max(0, contentHeight) - - -- Calculate stretch scales for edges and center - local scaleX = contentWidth / centerW - local scaleY = contentHeight / centerH - - -- Create quads for each region - local atlasWidth, atlasHeight = atlas:getDimensions() - - local function makeQuad(region) - return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight) - end - - -- Get corner scale multiplier - -- Priority: element-level override > component setting > default (nil = no scaling) - local scaleCorners = elementScaleCorners - if scaleCorners == nil then - scaleCorners = component.scaleCorners - end - - -- Priority: element-level override > component setting > default ("bilinear") - local scalingAlgorithm = elementScalingAlgorithm - if scalingAlgorithm == nil then - scalingAlgorithm = component.scalingAlgorithm or "bilinear" - end - - if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then - -- Initialize cache if needed - if not component._scaledRegionCache then - component._scaledRegionCache = {} - end - - -- Use the numeric scale multiplier directly - local scaleFactor = scaleCorners - - -- Helper to get or create scaled region - local function getScaledRegion(regionName, region, targetWidth, targetHeight) - local cacheKey = string.format("%s_%.2f_%s", regionName, scaleFactor, scalingAlgorithm) - - if component._scaledRegionCache[cacheKey] then - return component._scaledRegionCache[cacheKey] - end - - -- Get ImageData from component (stored during theme loading) - local atlasData = component._loadedAtlasData - if not atlasData then - error(formatError("NineSlice", "No ImageData available for atlas. Image must be loaded with safeLoadImage.")) - end - - local scaledData - - if scalingAlgorithm == "nearest" then - scaledData = ImageScaler.scaleNearest(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight) - else - scaledData = ImageScaler.scaleBilinear(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight) - end - - -- Convert to image and cache - local scaledImage = love.graphics.newImage(scaledData) - component._scaledRegionCache[cacheKey] = scaledImage - - return scaledImage - end - - -- Calculate scaled dimensions for corners - local scaledLeft = math.floor(left * scaleFactor + 0.5) - local scaledRight = math.floor(right * scaleFactor + 0.5) - local scaledTop = math.floor(top * scaleFactor + 0.5) - local scaledBottom = math.floor(bottom * scaleFactor + 0.5) - - -- CORNERS (scaled using algorithm) - local topLeftScaled = getScaledRegion("topLeft", regions.topLeft, scaledLeft, scaledTop) - local topRightScaled = getScaledRegion("topRight", regions.topRight, scaledRight, scaledTop) - local bottomLeftScaled = getScaledRegion("bottomLeft", regions.bottomLeft, scaledLeft, scaledBottom) - local bottomRightScaled = getScaledRegion("bottomRight", regions.bottomRight, scaledRight, scaledBottom) - - love.graphics.draw(topLeftScaled, x, y) - love.graphics.draw(topRightScaled, x + width - scaledRight, y) - love.graphics.draw(bottomLeftScaled, x, y + height - scaledBottom) - love.graphics.draw(bottomRightScaled, x + width - scaledRight, y + height - scaledBottom) - - -- Update content dimensions to account for scaled borders - local adjustedContentWidth = width - scaledLeft - scaledRight - local adjustedContentHeight = height - scaledTop - scaledBottom - adjustedContentWidth = math.max(0, adjustedContentWidth) - adjustedContentHeight = math.max(0, adjustedContentHeight) - - -- Recalculate stretch scales - local adjustedScaleX = adjustedContentWidth / centerW - local adjustedScaleY = adjustedContentHeight / centerH - - -- TOP/BOTTOM EDGES (stretch horizontally, scale vertically) - if adjustedContentWidth > 0 then - local topCenterScaled = getScaledRegion("topCenter", regions.topCenter, regions.topCenter.w, scaledTop) - local bottomCenterScaled = getScaledRegion("bottomCenter", regions.bottomCenter, regions.bottomCenter.w, scaledBottom) - - love.graphics.draw(topCenterScaled, x + scaledLeft, y, 0, adjustedScaleX, 1) - love.graphics.draw(bottomCenterScaled, x + scaledLeft, y + height - scaledBottom, 0, adjustedScaleX, 1) - end - - -- LEFT/RIGHT EDGES (stretch vertically, scale horizontally) - if adjustedContentHeight > 0 then - local middleLeftScaled = getScaledRegion("middleLeft", regions.middleLeft, scaledLeft, regions.middleLeft.h) - local middleRightScaled = getScaledRegion("middleRight", regions.middleRight, scaledRight, regions.middleRight.h) - - love.graphics.draw(middleLeftScaled, x, y + scaledTop, 0, 1, adjustedScaleY) - love.graphics.draw(middleRightScaled, x + width - scaledRight, y + scaledTop, 0, 1, adjustedScaleY) - end - - -- CENTER (stretch both dimensions, no scaling) - if adjustedContentWidth > 0 and adjustedContentHeight > 0 then - love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + scaledLeft, y + scaledTop, 0, adjustedScaleX, adjustedScaleY) - end - else - -- Original rendering logic (no scaling) - -- CORNERS (no scaling - 1:1 pixel perfect) - love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y) - love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y) - love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight) - love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight) - - -- TOP/BOTTOM EDGES (stretch horizontally only) - if contentWidth > 0 then - love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1) - love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1) - end - - -- LEFT/RIGHT EDGES (stretch vertically only) - if contentHeight > 0 then - love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY) - love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY) - end - - -- CENTER (stretch both dimensions) - if contentWidth > 0 and contentHeight > 0 then - love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY) - end - end - - -- Reset color - love.graphics.setColor(1, 1, 1, 1) -end - -local enums = { - ---@enum TextAlign - TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" }, - ---@enum Positioning - Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" }, - ---@enum FlexDirection - FlexDirection = { HORIZONTAL = "horizontal", VERTICAL = "vertical" }, - ---@enum JustifyContent - JustifyContent = { - FLEX_START = "flex-start", - CENTER = "center", - SPACE_AROUND = "space-around", - FLEX_END = "flex-end", - SPACE_EVENLY = "space-evenly", - SPACE_BETWEEN = "space-between", - }, - ---@enum JustifySelf - JustifySelf = { - AUTO = "auto", - FLEX_START = "flex-start", - CENTER = "center", - FLEX_END = "flex-end", - SPACE_AROUND = "space-around", - SPACE_EVENLY = "space-evenly", - SPACE_BETWEEN = "space-between", - }, - ---@enum AlignItems - AlignItems = { - STRETCH = "stretch", - FLEX_START = "flex-start", - FLEX_END = "flex-end", - CENTER = "center", - BASELINE = "baseline", - }, - ---@enum AlignSelf - AlignSelf = { - AUTO = "auto", - STRETCH = "stretch", - FLEX_START = "flex-start", - FLEX_END = "flex-end", - CENTER = "center", - BASELINE = "baseline", - }, - ---@enum AlignContent - AlignContent = { - STRETCH = "stretch", - FLEX_START = "flex-start", - FLEX_END = "flex-end", - CENTER = "center", - SPACE_BETWEEN = "space-between", - SPACE_AROUND = "space-around", - }, - ---@enum FlexWrap - FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" }, - ---@enum TextSize - TextSize = { - XXS = "xxs", - XS = "xs", - SM = "sm", - MD = "md", - LG = "lg", - XL = "xl", - XXL = "xxl", - XL3 = "3xl", - XL4 = "4xl", - }, -} - --- Text size preset mappings (in vh units for auto-scaling) -local TEXT_SIZE_PRESETS = { - ["2xs"] = 0.75, -- 0.75vh - xxs = 0.75, -- 0.75vh - xs = 1.25, -- 1.25vh - sm = 1.75, -- 1.75vh - md = 2.25, -- 2.25vh (default) - lg = 2.75, -- 2.75vh - xl = 3.5, -- 3.5vh - xxl = 4.5, -- 4.5vh - ["2xl"] = 4.5, -- 4.5vh - ["3xl"] = 5.0, -- 5vh - ["4xl"] = 7.0, -- 7vh -} - local Positioning, FlexDirection, JustifyContent, AlignContent, AlignItems, TextAlign, AlignSelf, JustifySelf, FlexWrap = enums.Positioning, enums.FlexDirection, @@ -1994,185 +63,6 @@ local Positioning, FlexDirection, JustifyContent, AlignContent, AlignItems, Text enums.JustifySelf, enums.FlexWrap --- ==================== --- Units System --- ==================== - ---- Unit parsing and viewport calculations -local Units = {} - ---- Parse a unit value (string or number) into value and unit type ----@param value string|number ----@return number, string -- Returns numeric value and unit type ("px", "%", "vw", "vh") -function Units.parse(value) - if type(value) == "number" then - return value, "px" - end - - if type(value) ~= "string" then - -- Fallback to 0px for invalid types - return 0, "px" - end - - -- Match number followed by optional unit - local numStr, unit = value:match("^([%-]?[%d%.]+)(.*)$") - if not numStr then - -- Fallback to 0px for invalid format - return 0, "px" - end - - local num = tonumber(numStr) - if not num then - -- Fallback to 0px for invalid numeric value - return 0, "px" - end - - -- Default to pixels if no unit specified - if unit == "" then - unit = "px" - end - - local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true } - if not validUnits[unit] then - return num, "px" - end - - return num, unit -end - ---- Convert relative units to pixels based on viewport and parent dimensions ----@param value number -- Numeric value to convert ----@param unit string -- Unit type ("px", "%", "vw", "vh", "ew", "eh") ----@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) ----@return number -- Resolved pixel value ----@throws Error if unit type is unknown or percentage used without parent size -function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize) - if unit == "px" then - return value - elseif unit == "%" then - if not parentSize then - error(formatError("Units", "Percentage units require parent dimension")) - end - return (value / 100) * parentSize - elseif unit == "vw" then - return (value / 100) * viewportWidth - elseif unit == "vh" then - return (value / 100) * viewportHeight - else - error(formatError("Units", string.format("Unknown unit type: '%s'. Valid units: px, %%, vw, vh, ew, eh", unit))) - end -end - ----@return number, number -- width, height -function Units.getViewport() - -- Return cached viewport if available (only during resize operations) - if Gui and Gui._cachedViewport and Gui._cachedViewport.width > 0 then - return Gui._cachedViewport.width, Gui._cachedViewport.height - end - - -- Query viewport dimensions normally - if love.graphics and love.graphics.getDimensions then - return love.graphics.getDimensions() - else - local w, h = love.window.getMode() - return w, h - end -end - ---- Apply base scaling to a value ----@param value number ----@param axis "x"|"y" -- Which axis to scale on ----@param scaleFactors {x:number, y:number} ----@return number -function Units.applyBaseScale(value, axis, scaleFactors) - if axis == "x" then - return value * scaleFactors.x - else - return value * scaleFactors.y - end -end - ---- Resolve units for spacing properties (padding, margin) ----@param spacingProps table? ----@param parentWidth number ----@param parentHeight number ----@return table -- Resolved spacing with top, right, bottom, left in pixels -function Units.resolveSpacing(spacingProps, parentWidth, parentHeight) - if not spacingProps then - return { top = 0, right = 0, bottom = 0, left = 0 } - end - - local viewportWidth, viewportHeight = Units.getViewport() - local result = {} - - -- Handle shorthand properties first - local vertical = spacingProps.vertical - local horizontal = spacingProps.horizontal - - if vertical then - if type(vertical) == "string" then - local value, unit = Units.parse(vertical) - vertical = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) - end - end - - if horizontal then - if type(horizontal) == "string" then - local value, unit = Units.parse(horizontal) - horizontal = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) - end - end - - -- Handle individual sides - for _, side in ipairs({ "top", "right", "bottom", "left" }) do - local value = spacingProps[side] - if value then - if type(value) == "string" 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) - else - result[side] = value - end - else - -- Use fallbacks - if side == "top" or side == "bottom" then - result[side] = vertical or 0 - else - result[side] = horizontal or 0 - end - end - end - - return result -end - ---- Check if a unit string is valid ----@param unitStr string -- Unit string to validate (e.g., "10px", "50%", "20vw") ----@return boolean -- Returns true if unit string is valid -function Units.isValid(unitStr) - if type(unitStr) ~= "string" then - return false - end - - local _, unit = Units.parse(unitStr) - local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true } - return validUnits[unit] == true -end - ---- Parse and resolve a unit value in one call ----@param value string|number -- Value to parse and resolve ----@param viewportWidth number -- Current viewport width ----@param viewportHeight number -- Current viewport height ----@param parentSize number? -- Parent dimension for percentage units ----@return number -- Resolved pixel value -function Units.parseAndResolve(value, viewportWidth, viewportHeight, parentSize) - local numValue, unit = Units.parse(value) - return Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize) -end - -- ==================== -- Grid System -- ==================== @@ -2702,4466 +592,6 @@ function InputEvent.new(props) return self end ---- Get current keyboard modifiers state ----@return {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} -local function getModifiers() - return { - shift = love.keyboard.isDown("lshift", "rshift"), - ctrl = love.keyboard.isDown("lctrl", "rctrl"), - alt = love.keyboard.isDown("lalt", "ralt"), - ---@diagnostic disable-next-line - super = love.keyboard.isDown("lgui", "rgui"), -- cmd/windows key - } -end - ----@class Animation ----@field duration number ----@field start {width?:number, height?:number, opacity?:number} ----@field final {width?:number, height?:number, opacity?:number} ----@field elapsed number ----@field transform table? ----@field transition table? ---- Easing functions for animations -local Easing = { - linear = function(t) - return t - end, - - easeInQuad = function(t) - return t * t - end, - easeOutQuad = function(t) - return t * (2 - t) - end, - easeInOutQuad = function(t) - return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t - end, - - easeInCubic = function(t) - return t * t * t - end, - easeOutCubic = function(t) - local t1 = t - 1 - return t1 * t1 * t1 + 1 - end, - easeInOutCubic = function(t) - return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 - end, - - easeInQuart = function(t) - return t * t * t * t - end, - easeOutQuart = function(t) - local t1 = t - 1 - return 1 - t1 * t1 * t1 * t1 - end, - - easeInExpo = function(t) - return t == 0 and 0 or math.pow(2, 10 * (t - 1)) - end, - easeOutExpo = function(t) - return t == 1 and 1 or 1 - math.pow(2, -10 * t) - end, -} - -local Animation = {} -Animation.__index = Animation - ----@class AnimationProps ----@field duration number ----@field start {width?:number, height?:number, opacity?:number} ----@field final {width?:number, height?:number, opacity?:number} ----@field transform table? ----@field transition table? -local AnimationProps = {} - ----@class TransformProps ----@field scale {x?:number, y?:number}? ----@field rotate number? ----@field translate {x?:number, y?:number}? ----@field skew {x?:number, y?:number}? - ----@class TransitionProps ----@field duration number? ----@field easing string? - ----@param props AnimationProps ----@return Animation -function Animation.new(props) - local self = setmetatable({}, Animation) - self.duration = props.duration - self.start = props.start - self.final = props.final - self.transform = props.transform - self.transition = props.transition - self.elapsed = 0 - - -- Set easing function (default to linear) - local easingName = props.easing or "linear" - self.easing = Easing[easingName] or Easing.linear - - -- Pre-allocate result table to avoid GC pressure - self._cachedResult = {} - self._resultDirty = true - - return self -end - ----@param dt number ----@return boolean -function Animation:update(dt) - self.elapsed = self.elapsed + dt - self._resultDirty = true -- Mark cached result as dirty - if self.elapsed >= self.duration then - return true -- finished - else - return false - end -end - ----@return table -function Animation:interpolate() - -- Return cached result if not dirty (avoids recalculation) - if not self._resultDirty then - return self._cachedResult - end - - local t = math.min(self.elapsed / self.duration, 1) - t = self.easing(t) -- Apply easing function - local result = self._cachedResult -- Reuse existing table - - -- Clear previous values - result.width = nil - result.height = nil - result.opacity = nil - - -- Handle width and height if present - if self.start.width and self.final.width then - result.width = self.start.width * (1 - t) + self.final.width * t - end - - if self.start.height and self.final.height then - result.height = self.start.height * (1 - t) + self.final.height * t - end - - -- Handle other properties like opacity - if self.start.opacity and self.final.opacity then - result.opacity = self.start.opacity * (1 - t) + self.final.opacity * t - end - - -- Apply transform if present - if self.transform then - for key, value in pairs(self.transform) do - result[key] = value - end - end - - self._resultDirty = false -- Mark as clean - return result -end - ---- Apply animation to a GUI element ----@param element Element -function Animation:apply(element) - if element.animation then - -- If there's an existing animation, we should probably stop it or replace it - element.animation = self - else - element.animation = self - end -end - ---- Create a simple fade animation ----@param duration number ----@param fromOpacity number ----@param toOpacity number ----@return Animation -function Animation.fade(duration, fromOpacity, toOpacity) - return Animation.new({ - duration = duration, - start = { opacity = fromOpacity }, - final = { opacity = toOpacity }, - transform = {}, - transition = {}, - }) -end - ---- Create a simple scale animation ----@param duration number ----@param fromScale table{width:number,height:number} ----@param toScale table{width:number,height:number} ----@return Animation -function Animation.scale(duration, fromScale, toScale) - return Animation.new({ - duration = duration, - start = { width = fromScale.width, height = fromScale.height }, - final = { width = toScale.width, height = toScale.height }, - transform = {}, - transition = {}, - }) -end - -local FONT_CACHE = {} -local FONT_CACHE_MAX_SIZE = 50 -- Limit cache size to prevent unbounded growth -local FONT_CACHE_ORDER = {} -- Track access order for LRU eviction - ---- Create or get a font from cache ----@param size number ----@param fontPath string? -- Optional: path to font file ----@return love.Font -function FONT_CACHE.get(size, fontPath) - -- Create cache key from size and font path - local cacheKey = fontPath and (fontPath .. "_" .. tostring(size)) or tostring(size) - - if not FONT_CACHE[cacheKey] then - if fontPath then - -- Load custom font - local resolvedPath = resolveImagePath(fontPath) - -- Note: love.graphics.newFont signature is (path, size) for custom fonts - local success, font = pcall(love.graphics.newFont, resolvedPath, size) - if success then - FONT_CACHE[cacheKey] = font - else - -- Fallback to default font if custom font fails to load - print("[FlexLove] Failed to load font: " .. fontPath .. " - using default font") - FONT_CACHE[cacheKey] = love.graphics.newFont(size) - end - else - -- Load default font - FONT_CACHE[cacheKey] = love.graphics.newFont(size) - end - - -- Add to access order for LRU tracking - table.insert(FONT_CACHE_ORDER, cacheKey) - - -- Evict oldest entry if cache is full (LRU eviction) - if #FONT_CACHE_ORDER > FONT_CACHE_MAX_SIZE then - local oldestKey = table.remove(FONT_CACHE_ORDER, 1) - FONT_CACHE[oldestKey] = nil - end - end - return FONT_CACHE[cacheKey] -end - ---- Get font for text size (cached) ----@param textSize number? ----@param fontPath string? -- Optional: path to font file ----@return love.Font -function FONT_CACHE.getFont(textSize, fontPath) - if textSize then - return FONT_CACHE.get(textSize, fontPath) - else - return love.graphics.getFont() - end -end - --- ==================== --- Text Size Utilities --- ==================== - ---- Resolve text size preset to viewport units ----@param sizeValue string|number ----@return number?, string? -- Returns value and unit ("vh" for presets, original unit otherwise) -local function resolveTextSizePreset(sizeValue) - if type(sizeValue) == "string" then - -- Check if it's a preset - local preset = TEXT_SIZE_PRESETS[sizeValue] - if preset then - return preset, "vh" - end - end - -- Not a preset, return nil to indicate normal parsing should occur - return nil, nil -end - ----@class Border ----@field top boolean? ----@field right boolean? ----@field bottom boolean? ----@field left boolean? - --- ==================== --- Element Object --- ==================== - ---[[ -INTERNAL FIELD NAMING CONVENTIONS: ---------------------------------- -Fields prefixed with underscore (_) are internal/private and should not be accessed directly: - -- _pressed: Internal state tracking for mouse button presses -- _lastClickTime: Internal timestamp for double-click detection -- _lastClickButton: Internal button tracking for click events -- _clickCount: Internal counter for multi-click detection -- _touchPressed: Internal touch state tracking -- _themeState: Internal current theme state (managed automatically) -- _borderBoxWidth: Internal cached border-box width (optimization) -- _borderBoxHeight: Internal cached border-box height (optimization) -- _explicitlyAbsolute: Internal flag for positioning logic -- _originalPositioning: Internal original positioning value -- _cachedResult: Internal animation cache (Animation class) -- _resultDirty: Internal animation dirty flag (Animation class) -- _loadedAtlas: Internal cached atlas image (ThemeComponent) -- _cachedViewport: Internal viewport cache (Gui class) - -Public API methods to access internal state: -- Element:getBorderBoxWidth() - Get border-box width -- Element:getBorderBoxHeight() - Get border-box height -- Element:getBounds() - Get element bounds -]] - ----@class Element ----@field id string ----@field autosizing {width:boolean, height:boolean} -- Whether the element should automatically size to fit its children ----@field x number|string -- X coordinate of the element ----@field y number|string -- Y coordinate of the element ----@field z number -- Z-index for layering (default: 0) ----@field width number|string -- Width of the element ----@field height number|string -- Height of the element ----@field top number? -- Offset from top edge (CSS-style positioning) ----@field right number? -- Offset from right edge (CSS-style positioning) ----@field bottom number? -- Offset from bottom edge (CSS-style positioning) ----@field left number? -- Offset from left edge (CSS-style positioning) ----@field children table -- Children of this element ----@field parent Element? -- Parent element (nil if top-level) ----@field border Border -- Border configuration for the element ----@field opacity number ----@field borderColor Color -- Color of the border ----@field backgroundColor Color -- Background color of the element ----@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius for rounded corners (default: 0) ----@field prevGameSize {width:number, height:number} -- Previous game size for resize calculations ----@field text string? -- Text content to display in the element ----@field textColor Color -- Color of the text content ----@field textAlign TextAlign -- Alignment of the text content ----@field gap number|string -- Space between children elements (default: 10) ----@field padding {top?:number, right?:number, bottom?:number, left?:number}? -- Padding around children (default: {top=0, right=0, bottom=0, left=0}) ----@field margin {top?:number, right?:number, bottom?:number, left?:number} -- Margin around children (default: {top=0, right=0, bottom=0, left=0}) ----@field positioning Positioning -- Layout positioning mode (default: RELATIVE) ----@field flexDirection FlexDirection -- Direction of flex layout (default: HORIZONTAL) ----@field justifyContent JustifyContent -- Alignment of items along main axis (default: FLEX_START) ----@field alignItems AlignItems -- Alignment of items along cross axis (default: STRETCH) ----@field alignContent AlignContent -- Alignment of lines in multi-line flex containers (default: STRETCH) ----@field flexWrap FlexWrap -- Whether children wrap to multiple lines (default: NOWRAP) ----@field justifySelf JustifySelf -- Alignment of the item itself along main axis (default: AUTO) ----@field alignSelf AlignSelf -- Alignment of the item itself along cross axis (default: AUTO) ----@field textSize number? -- Resolved font size for text content in pixels ----@field minTextSize number? ----@field maxTextSize number? ----@field fontFamily string? -- Font family name from theme or path to font file ----@field autoScaleText boolean -- Whether text should auto-scale with window size (default: true) ----@field transform TransformProps -- Transform properties for animations and styling ----@field transition TransitionProps -- Transition settings for animations ----@field callback fun(element:Element, event:InputEvent)? -- Callback function for interaction events ----@field units table -- Original unit specifications for responsive behavior ----@field _pressed table -- Track pressed state per mouse button ----@field _lastClickTime number? -- Timestamp of last click for double-click detection ----@field _lastClickButton number? -- Button of last click ----@field _clickCount number -- Current click count for multi-click detection ----@field _touchPressed table -- Track touch pressed state ----@field _dragStartX table? -- Track drag start X position per mouse button ----@field _dragStartY table? -- Track drag start Y position per mouse button ----@field _lastMouseX table? -- Last known mouse X position per button for drag tracking ----@field _lastMouseY table? -- Last known mouse Y position per button for drag tracking ----@field _explicitlyAbsolute boolean? ----@field gridRows number? -- Number of rows in the grid ----@field gridColumns number? -- Number of columns in the grid ----@field columnGap number|string? -- Gap between grid columns ----@field rowGap number|string? -- Gap between grid rows ----@field theme string? -- Theme component to use for rendering ----@field themeComponent string? ----@field _themeState string? -- Current theme state (normal, hover, pressed, active, disabled) ----@field disabled boolean? -- Whether the element is disabled (default: false) ----@field active boolean? -- Whether the element is active/focused (for inputs, default: false) ----@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false) ----@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions ----@field scaleCorners number? -- Scale multiplier for 9-slice corners/edges. E.g., 2 = 2x size (overrides theme setting) ----@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-slice corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting) ----@field contentBlur {intensity:number, quality:number}? -- Blur the element's content including children (intensity: 0-100, quality: 1-10) ----@field backdropBlur {intensity:number, quality:number}? -- Blur content behind the element (intensity: 0-100, quality: 1-10) ----@field _blurInstance table? -- Internal: cached blur effect instance ----@field editable boolean -- Whether the element is editable (default: false) ----@field multiline boolean -- Whether the element supports multiple lines (default: false) ----@field textWrap boolean|"word"|"char" -- Text wrapping mode (default: false for single-line, "word" for multi-line) ----@field maxLines number? -- Maximum number of lines (default: nil) ----@field maxLength number? -- Maximum text length in characters (default: nil) ----@field placeholder string? -- Placeholder text when empty (default: nil) ----@field passwordMode boolean -- Whether to display text as password (default: false) ----@field inputType "text"|"number"|"email"|"url" -- Input type for validation (default: "text") ----@field textOverflow "clip"|"ellipsis"|"scroll" -- Text overflow behavior (default: "clip") ----@field scrollable boolean -- Whether text is scrollable (default: false for single-line, true for multi-line) ----@field autoGrow boolean -- Whether element auto-grows with text (default: false) ----@field selectOnFocus boolean -- Whether to select all text on focus (default: false) ----@field cursorColor Color? -- Cursor color (default: nil, uses textColor) ----@field selectionColor Color? -- Selection background color (default: nil, uses theme or default) ----@field cursorBlinkRate number -- Cursor blink rate in seconds (default: 0.5) ----@field _cursorPosition number? -- Internal: cursor character position (0-based) ----@field _cursorLine number? -- Internal: cursor line number (1-based) ----@field _cursorColumn number? -- Internal: cursor column within line ----@field _cursorBlinkTimer number? -- Internal: cursor blink timer ----@field _cursorVisible boolean? -- Internal: cursor visibility state ----@field _selectionStart number? -- Internal: selection start position ----@field _selectionEnd number? -- Internal: selection end position ----@field _selectionAnchor number? -- Internal: selection anchor point ----@field _focused boolean? -- Internal: focus state ----@field _textBuffer string? -- Internal: text buffer for editable elements ----@field _lines table? -- Internal: split lines for multi-line text ----@field _wrappedLines table? -- Internal: wrapped line data ----@field _textDirty boolean? -- Internal: flag to recalculate lines/wrapping ----@field imagePath string? -- Path to image file (auto-loads via ImageCache) ----@field image love.Image? -- Image object to display ----@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 _loadedImage love.Image? -- Internal: cached loaded image -local Element = {} -Element.__index = Element - ----@class ElementProps ----@field id string? ----@field parent Element? -- Parent element for hierarchical structure ----@field x number|string? -- X coordinate of the element (default: 0) ----@field y number|string? -- Y coordinate of the element (default: 0) ----@field z number? -- Z-index for layering (default: 0) ----@field width number|string? -- Width of the element (default: calculated automatically) ----@field height number|string? -- Height of the element (default: calculated automatically) ----@field top number|string? -- Offset from top edge (CSS-style positioning) ----@field right number|string? -- Offset from right edge (CSS-style positioning) ----@field bottom number|string? -- Offset from bottom edge (CSS-style positioning) ----@field left number|string? -- Offset from left edge (CSS-style positioning) ----@field border Border? -- Border configuration for the element ----@field borderColor Color? -- Color of the border (default: black) ----@field opacity number? ----@field backgroundColor Color? -- Background color (default: transparent) ----@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius: number (all corners) or table for individual corners (default: 0) ----@field gap number|string? -- Space between children elements (default: 10) ----@field padding {top:number|string?, right:number|string?, bottom:number|string?, left:number|string?, horizontal: number|string?, vertical:number|string?}? -- Padding around children (default: {top=0, right=0, bottom=0, left=0}) ----@field margin {top:number|string?, right:number|string?, bottom:number|string?, left:number|string?, horizontal: number|string?, vertical:number|string?}? -- Margin around children (default: {top=0, right=0, bottom=0, left=0}) ----@field text string? -- Text content to display (default: nil) ----@field titleColor Color? -- Color of the text content (default: black) ----@field textAlign TextAlign? -- Alignment of the text content (default: START) ----@field textColor Color? -- Color of the text content (default: black) ----@field textSize number|string? -- Font size: number (px), string with units ("2vh", "10%"), or preset ("xxs"|"xs"|"sm"|"md"|"lg"|"xl"|"xxl"|"3xl"|"4xl") (default: "md") ----@field minTextSize number? ----@field maxTextSize number? ----@field fontFamily string? -- Font family name from theme or path to font file (default: theme default or system default) ----@field autoScaleText boolean? -- Whether text should auto-scale with window size (default: true) ----@field positioning Positioning? -- Layout positioning mode (default: RELATIVE) ----@field flexDirection FlexDirection? -- Direction of flex layout (default: HORIZONTAL) ----@field justifyContent JustifyContent? -- Alignment of items along main axis (default: FLEX_START) ----@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH) ----@field alignContent AlignContent? -- Alignment of lines in multi-line flex containers (default: STRETCH) ----@field flexWrap FlexWrap? -- Whether children wrap to multiple lines (default: NOWRAP) ----@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO) ----@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO) ----@field callback fun(element:Element, event:InputEvent)? -- Callback function for interaction events ----@field transform table? -- Transform properties for animations and styling ----@field transition table? -- Transition settings for animations ----@field gridRows number? -- Number of rows in the grid (default: 1) ----@field gridColumns number? -- Number of columns in the grid (default: 1) ----@field columnGap number|string? -- Gap between grid columns ----@field rowGap number|string? -- Gap between grid rows ----@field theme string? -- Theme name to use (e.g., "space", "dark"). Defaults to theme from Gui.init() ----@field themeComponent string? -- Theme component to use (e.g., "panel", "button", "input"). If nil, no theme is applied ----@field disabled boolean? -- Whether the element is disabled (default: false) ----@field active boolean? -- Whether the element is active/focused (for inputs, default: false) ----@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false) ----@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme) ----@field scaleCorners number? -- Scale multiplier for 9-slice corners/edges. E.g., 2 = 2x size (overrides theme setting) ----@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-slice corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting) ----@field contentBlur {intensity:number, quality:number}? -- Blur the element's content including children (intensity: 0-100, quality: 1-10, default: nil) ----@field backdropBlur {intensity:number, quality:number}? -- Blur content behind the element (intensity: 0-100, quality: 1-10, default: nil) ----@field editable boolean? -- Whether the element is editable (default: false) ----@field multiline boolean? -- Whether the element supports multiple lines (default: false) ----@field textWrap boolean|"word"|"char"? -- Text wrapping mode (default: false for single-line, "word" for multi-line) ----@field maxLines number? -- Maximum number of lines (default: nil) ----@field maxLength number? -- Maximum text length in characters (default: nil) ----@field placeholder string? -- Placeholder text when empty (default: nil) ----@field passwordMode boolean? -- Whether to display text as password (default: false) ----@field inputType "text"|"number"|"email"|"url"? -- Input type for validation (default: "text") ----@field textOverflow "clip"|"ellipsis"|"scroll"? -- Text overflow behavior (default: "clip") ----@field scrollable boolean? -- Whether text is scrollable (default: false for single-line, true for multi-line) ----@field autoGrow boolean? -- Whether element auto-grows with text (default: false) ----@field selectOnFocus boolean? -- Whether to select all text on focus (default: false) ----@field cursorColor Color? -- Cursor color (default: nil, uses textColor) ----@field selectionColor Color? -- Selection background color (default: nil, uses theme or default) ----@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5) ----@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "visible") ----@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow) ----@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow) ----@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12) ----@field scrollbarColor Color? -- Scrollbar thumb color ----@field scrollbarTrackColor Color? -- Scrollbar track color ----@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6) ----@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2) ----@field scrollSpeed number? -- Pixels per wheel notch (default: 20) -local ElementProps = {} - ----@param props ElementProps ----@return Element -function Element.new(props) - local self = setmetatable({}, Element) - self.children = {} - self.callback = props.callback - self.id = props.id or "" - - -- Input event callbacks - self.onFocus = props.onFocus - self.onBlur = props.onBlur - self.onTextInput = props.onTextInput - self.onTextChange = props.onTextChange - self.onEnter = props.onEnter - - -- Initialize click tracking for event system - self._pressed = {} -- Track pressed state per mouse button - self._lastClickTime = nil - self._lastClickButton = nil - self._clickCount = 0 - self._touchPressed = {} - - -- Initialize drag tracking for event system - self._dragStartX = {} -- Track drag start X position per mouse button - self._dragStartY = {} -- Track drag start Y position per mouse button - self._lastMouseX = {} -- Track last mouse X position per button - self._lastMouseY = {} -- Track last mouse Y position per button - - -- Initialize theme - self._themeState = "normal" - - -- Handle theme property: - -- - theme: which theme to use (defaults to Gui.defaultTheme if not specified) - -- - themeComponent: which component from the theme (e.g., "panel", "button", "input") - -- If themeComponent is nil, no theme is applied (manual styling) - self.theme = props.theme or Gui.defaultTheme - self.themeComponent = props.themeComponent or nil - - -- Initialize state properties - self.disabled = props.disabled or false - self.active = props.active or false - - -- disableHighlight defaults to true when using themeComponent (themes handle their own visual feedback) - -- Can be explicitly overridden by setting props.disableHighlight - if props.disableHighlight ~= nil then - self.disableHighlight = props.disableHighlight - else - self.disableHighlight = self.themeComponent ~= nil - end - - -- Initialize contentAutoSizingMultiplier after theme is set - -- Priority: element props > theme component > theme default - if props.contentAutoSizingMultiplier then - -- Explicitly set on element - self.contentAutoSizingMultiplier = props.contentAutoSizingMultiplier - else - -- Try to source from theme - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse then - -- First check if themeComponent has a multiplier - if self.themeComponent then - local component = themeToUse.components[self.themeComponent] - if component and component.contentAutoSizingMultiplier then - self.contentAutoSizingMultiplier = component.contentAutoSizingMultiplier - elseif themeToUse.contentAutoSizingMultiplier then - -- Fall back to theme default - self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier - else - self.contentAutoSizingMultiplier = { 1, 1 } - end - elseif themeToUse.contentAutoSizingMultiplier then - self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier - else - self.contentAutoSizingMultiplier = { 1, 1 } - end - else - self.contentAutoSizingMultiplier = { 1, 1 } - end - end - - -- Initialize 9-slice corner scaling properties - -- These override theme component settings when specified - self.scaleCorners = props.scaleCorners - self.scalingAlgorithm = props.scalingAlgorithm - - -- Initialize blur properties - self.contentBlur = props.contentBlur - self.backdropBlur = props.backdropBlur - self._blurInstance = nil - - -- Initialize input control properties - self.editable = props.editable or false - self.multiline = props.multiline or false - self.passwordMode = props.passwordMode or false - - -- Validate property combinations: passwordMode disables multiline - if self.passwordMode then - self.multiline = false - end - - self.textWrap = props.textWrap - if self.textWrap == nil then - self.textWrap = self.multiline and "word" or false - end - - self.maxLines = props.maxLines - self.maxLength = props.maxLength - self.placeholder = props.placeholder - self.inputType = props.inputType or "text" - - -- Text behavior properties - self.textOverflow = props.textOverflow or "clip" - self.scrollable = props.scrollable - if self.scrollable == nil then - self.scrollable = self.multiline - end - self.autoGrow = props.autoGrow or false - self.selectOnFocus = props.selectOnFocus or false - - -- Cursor and selection properties - self.cursorColor = props.cursorColor - self.selectionColor = props.selectionColor - self.cursorBlinkRate = props.cursorBlinkRate or 0.5 - - -- Initialize cursor and selection state (only if editable) - if self.editable then - self._cursorPosition = 0 -- Character index (0 = before first char) - self._cursorLine = 1 -- Current line number (1-based) - self._cursorColumn = 0 -- Column within current line - self._cursorBlinkTimer = 0 - self._cursorVisible = true - - -- Selection state - self._selectionStart = nil -- nil = no selection - self._selectionEnd = nil - self._selectionAnchor = nil -- Anchor point for shift+arrow selection - - -- Focus state - self._focused = false - - -- Text buffer state (initialized after self.text is set below) - self._textBuffer = props.text or "" -- Actual text content - self._lines = nil -- Split lines (for multiline) - self._wrappedLines = nil -- Wrapped line data - self._textDirty = true -- Flag to recalculate lines/wrapping - end - - -- Set parent first so it's available for size calculations - self.parent = props.parent - - ------ add non-hereditary ------ - --- self drawing--- - self.border = props.border - and { - top = props.border.top or false, - right = props.border.right or false, - bottom = props.border.bottom or false, - left = props.border.left or false, - } - or { - top = false, - right = false, - bottom = false, - left = false, - } - self.borderColor = props.borderColor or Color.new(0, 0, 0, 1) - self.backgroundColor = props.backgroundColor or Color.new(0, 0, 0, 0) - self.opacity = props.opacity or 1 - - -- Handle cornerRadius (can be number or table) - if props.cornerRadius then - if type(props.cornerRadius) == "number" then - self.cornerRadius = { - topLeft = props.cornerRadius, - topRight = props.cornerRadius, - bottomLeft = props.cornerRadius, - bottomRight = props.cornerRadius, - } - else - self.cornerRadius = { - topLeft = props.cornerRadius.topLeft or 0, - topRight = props.cornerRadius.topRight or 0, - bottomLeft = props.cornerRadius.bottomLeft or 0, - bottomRight = props.cornerRadius.bottomRight or 0, - } - end - else - self.cornerRadius = { - topLeft = 0, - topRight = 0, - bottomLeft = 0, - bottomRight = 0, - } - end - - self.text = props.text - self.textAlign = props.textAlign or TextAlign.START - - -- Image properties - self.imagePath = props.imagePath - self.image = props.image - self.objectFit = props.objectFit or "fill" - self.objectPosition = props.objectPosition or "center center" - self.imageOpacity = props.imageOpacity or 1 - - -- Auto-load image if imagePath is provided - if self.imagePath and not self.image then - local loadedImage, err = ImageCache.load(self.imagePath) - if loadedImage then - self._loadedImage = loadedImage - else - -- Silently fail - image will just not render - self._loadedImage = nil - end - elseif self.image then - self._loadedImage = self.image - else - self._loadedImage = nil - end - - --- self positioning --- - local viewportWidth, viewportHeight = Units.getViewport() - - ---- Sizing ---- - local gw, gh = love.window.getMode() - self.prevGameSize = { width = gw, height = gh } - self.autosizing = { width = false, height = false } - - -- Store unit specifications for responsive behavior - self.units = { - width = { value = nil, unit = "px" }, - height = { value = nil, unit = "px" }, - x = { value = nil, unit = "px" }, - y = { value = nil, unit = "px" }, - textSize = { value = nil, unit = "px" }, - gap = { value = nil, unit = "px" }, - padding = { - top = { value = nil, unit = "px" }, - right = { value = nil, unit = "px" }, - bottom = { value = nil, unit = "px" }, - left = { value = nil, unit = "px" }, - horizontal = { value = nil, unit = "px" }, -- Shorthand for left/right - vertical = { value = nil, unit = "px" }, -- Shorthand for top/bottom - }, - margin = { - top = { value = nil, unit = "px" }, - right = { value = nil, unit = "px" }, - bottom = { value = nil, unit = "px" }, - left = { value = nil, unit = "px" }, - horizontal = { value = nil, unit = "px" }, -- Shorthand for left/right - vertical = { value = nil, unit = "px" }, -- Shorthand for top/bottom - }, - } - - -- Get scale factors from Gui (will be used later) - local scaleX, scaleY = Gui.getScaleFactors() - - -- Store original textSize units and constraints - self.minTextSize = props.minTextSize - self.maxTextSize = props.maxTextSize - - -- Set autoScaleText BEFORE textSize processing (needed for correct initialization) - if props.autoScaleText == nil then - self.autoScaleText = true - else - self.autoScaleText = props.autoScaleText - end - - -- Handle fontFamily (can be font name from theme or direct path to font file) - -- Priority: explicit props.fontFamily > parent fontFamily > theme default - if props.fontFamily then - -- Explicitly set fontFamily takes highest priority - self.fontFamily = props.fontFamily - elseif self.parent and self.parent.fontFamily then - -- Inherit from parent if parent has fontFamily set - self.fontFamily = self.parent.fontFamily - elseif props.themeComponent then - -- If using themeComponent, try to get default from theme - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts["default"] then - self.fontFamily = "default" - else - self.fontFamily = nil - end - else - self.fontFamily = nil - end - - -- Handle textSize BEFORE width/height calculation (needed for auto-sizing) - if props.textSize then - if type(props.textSize) == "string" then - -- Check if it's a preset first - local presetValue, presetUnit = resolveTextSizePreset(props.textSize) - local value, unit - - if presetValue then - -- It's a preset, use the preset value and unit - value, unit = presetValue, presetUnit - self.units.textSize = { value = value, unit = unit } - else - -- Not a preset, parse normally - value, unit = Units.parse(props.textSize) - self.units.textSize = { value = value, unit = unit } - end - - -- Resolve textSize based on unit type - if unit == "%" or unit == "vh" then - -- Percentage and vh are relative to viewport height - self.textSize = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) - elseif unit == "vw" then - -- vw is relative to viewport width - self.textSize = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) - elseif unit == "ew" then - -- ew is relative to element width (use viewport width as fallback during initialization) - -- Will be re-resolved after width is set - self.textSize = (value / 100) * viewportWidth - elseif unit == "eh" then - -- eh is relative to element height (use viewport height as fallback during initialization) - -- Will be re-resolved after height is set - self.textSize = (value / 100) * viewportHeight - elseif unit == "px" then - -- Pixel units - self.textSize = value - else - error("Unknown textSize unit: " .. unit) - end - else - -- Validate pixel textSize value - if props.textSize <= 0 then - error("textSize must be greater than 0, got: " .. tostring(props.textSize)) - end - - -- Pixel textSize value - if self.autoScaleText and Gui.baseScale then - -- With base scaling: store original pixel value and scale relative to base resolution - self.units.textSize = { value = props.textSize, unit = "px" } - self.textSize = props.textSize * scaleY - elseif self.autoScaleText then - -- Without base scaling: convert to viewport units for auto-scaling - -- Calculate what percentage of viewport height this represents - local vhValue = (props.textSize / viewportHeight) * 100 - self.units.textSize = { value = vhValue, unit = "vh" } - self.textSize = props.textSize -- Initial size is the specified pixel value - else - -- No auto-scaling: apply base scaling if set, otherwise use raw value - self.textSize = Gui.baseScale and (props.textSize * scaleY) or props.textSize - self.units.textSize = { value = props.textSize, unit = "px" } - end - end - else - -- No textSize specified - use auto-scaling default - if self.autoScaleText and Gui.baseScale then - -- With base scaling: use 12px as default and scale - self.units.textSize = { value = 12, unit = "px" } - self.textSize = 12 * scaleY - elseif self.autoScaleText then - -- Without base scaling: default to 1.5vh (1.5% of viewport height) - self.units.textSize = { value = 1.5, unit = "vh" } - self.textSize = (1.5 / 100) * viewportHeight - else - -- No auto-scaling: use 12px with optional base scaling - self.textSize = Gui.baseScale and (12 * scaleY) or 12 - self.units.textSize = { value = nil, unit = "px" } - end - end - - -- Handle width (both w and width properties, prefer w if both exist) - local widthProp = props.width - local tempWidth = 0 -- Temporary width for padding resolution - if widthProp then - if type(widthProp) == "string" then - local value, unit = Units.parse(widthProp) - self.units.width = { value = value, unit = unit } - local parentWidth = self.parent and self.parent.width or viewportWidth - tempWidth = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) - else - -- Apply base scaling to pixel values - tempWidth = Gui.baseScale and (widthProp * scaleX) or widthProp - self.units.width = { value = widthProp, unit = "px" } - end - self.width = tempWidth - else - self.autosizing.width = true - -- Calculate auto-width without padding first - tempWidth = self:calculateAutoWidth() - self.width = tempWidth - self.units.width = { value = nil, unit = "auto" } -- Mark as auto-sized - end - - -- Handle height (both h and height properties, prefer h if both exist) - local heightProp = props.height - local tempHeight = 0 -- Temporary height for padding resolution - if heightProp then - if type(heightProp) == "string" then - local value, unit = Units.parse(heightProp) - self.units.height = { value = value, unit = unit } - local parentHeight = self.parent and self.parent.height or viewportHeight - tempHeight = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) - else - -- Apply base scaling to pixel values - tempHeight = Gui.baseScale and (heightProp * scaleY) or heightProp - self.units.height = { value = heightProp, unit = "px" } - end - self.height = tempHeight - else - self.autosizing.height = true - -- Calculate auto-height without padding first - tempHeight = self:calculateAutoHeight() - self.height = tempHeight - self.units.height = { value = nil, unit = "auto" } -- Mark as auto-sized - end - - --- child positioning --- - if props.gap then - if type(props.gap) == "string" then - local value, unit = Units.parse(props.gap) - self.units.gap = { value = value, unit = unit } - -- Gap percentages should be relative to the element's own size, not parent - -- For horizontal flex, gap is based on width; for vertical flex, based on height - local flexDir = props.flexDirection or FlexDirection.HORIZONTAL - local containerSize = (flexDir == FlexDirection.HORIZONTAL) and self.width or self.height - self.gap = Units.resolve(value, unit, viewportWidth, viewportHeight, containerSize) - else - self.gap = props.gap - self.units.gap = { value = props.gap, unit = "px" } - end - else - self.gap = 0 - self.units.gap = { value = 0, unit = "px" } - end - - -- BORDER-BOX MODEL: For auto-sizing, we need to add padding to content dimensions - -- For explicit sizing, width/height already include padding (border-box) - - -- Check if we should use 9-patch content padding for auto-sizing - local use9PatchPadding = false - local ninePatchContentPadding = nil - if self.themeComponent then - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.components[self.themeComponent] then - local component = themeToUse.components[self.themeComponent] - if component._ninePatchData and component._ninePatchData.contentPadding then - -- Only use 9-patch padding if no explicit padding was provided - if - not props.padding - or ( - not props.padding.top - and not props.padding.right - and not props.padding.bottom - and not props.padding.left - and not props.padding.horizontal - and not props.padding.vertical - ) - then - use9PatchPadding = true - ninePatchContentPadding = component._ninePatchData.contentPadding - end - end - end - end - - -- First, resolve padding using temporary dimensions - -- For auto-sized elements, this is content width; for explicit sizing, this is border-box width - local tempPadding - if use9PatchPadding then - -- Scale 9-patch content padding to match the actual rendered size - -- The contentPadding values are in the original image's pixel coordinates, - -- but we need to scale them proportionally to the element's actual size - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.components[self.themeComponent] then - local component = themeToUse.components[self.themeComponent] - local atlasImage = component._loadedAtlas or themeToUse.atlas - - if atlasImage and type(atlasImage) ~= "string" then - local originalWidth, originalHeight = atlasImage:getDimensions() - - -- Calculate the scale factor based on the element's border-box size vs original image size - -- For explicit sizing, tempWidth/tempHeight represent the border-box dimensions - local scaleX = tempWidth / originalWidth - local scaleY = tempHeight / originalHeight - - tempPadding = { - left = ninePatchContentPadding.left * scaleX, - top = ninePatchContentPadding.top * scaleY, - right = ninePatchContentPadding.right * scaleX, - bottom = ninePatchContentPadding.bottom * scaleY, - } - else - -- Fallback if atlas image not available - tempPadding = { - left = ninePatchContentPadding.left, - top = ninePatchContentPadding.top, - right = ninePatchContentPadding.right, - bottom = ninePatchContentPadding.bottom, - } - end - else - -- Fallback if theme not found - tempPadding = { - left = ninePatchContentPadding.left, - top = ninePatchContentPadding.top, - right = ninePatchContentPadding.right, - bottom = ninePatchContentPadding.bottom, - } - end - else - tempPadding = Units.resolveSpacing(props.padding, self.width, self.height) - end - - -- Margin percentages are relative to parent's dimensions (CSS spec) - local parentWidth = self.parent and self.parent.width or viewportWidth - local parentHeight = self.parent and self.parent.height or viewportHeight - self.margin = Units.resolveSpacing(props.margin, parentWidth, parentHeight) - - -- For auto-sized elements, add padding to get border-box dimensions - if self.autosizing.width then - self._borderBoxWidth = self.width + tempPadding.left + tempPadding.right - else - -- For explicit sizing, width is already border-box - self._borderBoxWidth = self.width - end - - if self.autosizing.height then - self._borderBoxHeight = self.height + tempPadding.top + tempPadding.bottom - else - -- For explicit sizing, height is already border-box - self._borderBoxHeight = self.height - end - - -- Set final padding - if use9PatchPadding then - -- Use 9-patch content padding - self.padding = { - left = ninePatchContentPadding.left, - top = ninePatchContentPadding.top, - right = ninePatchContentPadding.right, - bottom = ninePatchContentPadding.bottom, - } - else - -- Re-resolve padding based on final border-box dimensions (important for percentage padding) - self.padding = Units.resolveSpacing(props.padding, self._borderBoxWidth, self._borderBoxHeight) - end - - -- Calculate final content dimensions by subtracting padding from border-box - self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) - self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) - - -- Re-resolve ew/eh textSize units now that width/height are set - if props.textSize and type(props.textSize) == "string" then - local value, unit = Units.parse(props.textSize) - if unit == "ew" then - -- Element width relative (now that width is set) - self.textSize = (value / 100) * self.width - elseif unit == "eh" then - -- Element height relative (now that height is set) - self.textSize = (value / 100) * self.height - end - end - - -- Apply min/max constraints (also scaled) - local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize) - local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) - - if minSize and self.textSize < minSize then - self.textSize = minSize - end - if maxSize and self.textSize > maxSize then - self.textSize = maxSize - end - - -- Protect against too-small text sizes (minimum 1px) - if self.textSize < 1 then - self.textSize = 1 -- Minimum 1px - end - - -- Store original spacing values for proper resize handling - -- Store shorthand properties first (horizontal/vertical) - if props.padding then - if props.padding.horizontal then - if type(props.padding.horizontal) == "string" then - local value, unit = Units.parse(props.padding.horizontal) - self.units.padding.horizontal = { value = value, unit = unit } - else - self.units.padding.horizontal = { value = props.padding.horizontal, unit = "px" } - end - end - if props.padding.vertical then - if type(props.padding.vertical) == "string" then - local value, unit = Units.parse(props.padding.vertical) - self.units.padding.vertical = { value = value, unit = unit } - else - self.units.padding.vertical = { value = props.padding.vertical, unit = "px" } - end - end - end - - -- Initialize all padding sides - for _, side in ipairs({ "top", "right", "bottom", "left" }) do - if props.padding and props.padding[side] then - if type(props.padding[side]) == "string" then - local value, unit = Units.parse(props.padding[side]) - self.units.padding[side] = { value = value, unit = unit, explicit = true } - else - self.units.padding[side] = { value = props.padding[side], unit = "px", explicit = true } - end - else - -- Mark as derived from shorthand (will use shorthand during resize if available) - self.units.padding[side] = { value = self.padding[side], unit = "px", explicit = false } - end - end - - -- Store margin shorthand properties - if props.margin then - if props.margin.horizontal then - if type(props.margin.horizontal) == "string" then - local value, unit = Units.parse(props.margin.horizontal) - self.units.margin.horizontal = { value = value, unit = unit } - else - self.units.margin.horizontal = { value = props.margin.horizontal, unit = "px" } - end - end - if props.margin.vertical then - if type(props.margin.vertical) == "string" then - local value, unit = Units.parse(props.margin.vertical) - self.units.margin.vertical = { value = value, unit = unit } - else - self.units.margin.vertical = { value = props.margin.vertical, unit = "px" } - end - end - end - - -- Initialize all margin sides - for _, side in ipairs({ "top", "right", "bottom", "left" }) do - if props.margin and props.margin[side] then - if type(props.margin[side]) == "string" then - local value, unit = Units.parse(props.margin[side]) - self.units.margin[side] = { value = value, unit = unit, explicit = true } - else - self.units.margin[side] = { value = props.margin[side], unit = "px", explicit = true } - end - else - -- Mark as derived from shorthand (will use shorthand during resize if available) - self.units.margin[side] = { value = self.margin[side], unit = "px", explicit = false } - end - end - - -- Grid properties are set later in the constructor - - ------ add hereditary ------ - if props.parent == nil then - table.insert(Gui.topElements, self) - - -- Handle x position with units - if props.x then - if type(props.x) == "string" then - local value, unit = Units.parse(props.x) - self.units.x = { value = value, unit = unit } - self.x = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) - else - -- Apply base scaling to pixel positions - self.x = Gui.baseScale and (props.x * scaleX) or props.x - self.units.x = { value = props.x, unit = "px" } - end - else - self.x = 0 - self.units.x = { value = 0, unit = "px" } - end - - -- Handle y position with units - if props.y then - if type(props.y) == "string" then - local value, unit = Units.parse(props.y) - self.units.y = { value = value, unit = unit } - self.y = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) - else - -- Apply base scaling to pixel positions - self.y = Gui.baseScale and (props.y * scaleY) or props.y - self.units.y = { value = props.y, unit = "px" } - end - else - self.y = 0 - self.units.y = { value = 0, unit = "px" } - end - - self.z = props.z or 0 - - -- Set textColor with priority: props > theme text color > black - if props.textColor then - self.textColor = props.textColor - else - -- Try to get text color from theme - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.colors and themeToUse.colors.text then - self.textColor = themeToUse.colors.text - else - -- Fallback to black - self.textColor = Color.new(0, 0, 0, 1) - end - end - - -- Track if positioning was explicitly set - if props.positioning then - self.positioning = props.positioning - self._originalPositioning = props.positioning - self._explicitlyAbsolute = (props.positioning == Positioning.ABSOLUTE) - else - self.positioning = Positioning.RELATIVE - self._originalPositioning = nil -- No explicit positioning - self._explicitlyAbsolute = false - end - else - -- Set positioning first and track if explicitly set - self._originalPositioning = props.positioning -- Track original intent - if props.positioning == Positioning.ABSOLUTE then - self.positioning = Positioning.ABSOLUTE - self._explicitlyAbsolute = true -- Explicitly set to absolute by user - elseif props.positioning == Positioning.FLEX then - self.positioning = Positioning.FLEX - self._explicitlyAbsolute = false - elseif props.positioning == Positioning.GRID then - self.positioning = Positioning.GRID - self._explicitlyAbsolute = false - else - -- Default: children in flex/grid containers participate in parent's layout - -- children in relative/absolute containers default to relative - if self.parent.positioning == Positioning.FLEX or self.parent.positioning == Positioning.GRID then - self.positioning = Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid - self._explicitlyAbsolute = false -- Participate in parent's layout - else - self.positioning = Positioning.RELATIVE - self._explicitlyAbsolute = false -- Default for relative/absolute containers - end - end - - -- Set initial position - if self.positioning == Positioning.ABSOLUTE then - -- Handle x position with units - if props.x then - if type(props.x) == "string" then - local value, unit = Units.parse(props.x) - self.units.x = { value = value, unit = unit } - local parentWidth = self.parent.width - self.x = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) - else - -- Apply base scaling to pixel positions - self.x = Gui.baseScale and (props.x * scaleX) or props.x - self.units.x = { value = props.x, unit = "px" } - end - else - self.x = 0 - self.units.x = { value = 0, unit = "px" } - end - - -- Handle y position with units - if props.y then - if type(props.y) == "string" then - local value, unit = Units.parse(props.y) - self.units.y = { value = value, unit = unit } - local parentHeight = self.parent.height - self.y = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) - else - -- Apply base scaling to pixel positions - self.y = Gui.baseScale and (props.y * scaleY) or props.y - self.units.y = { value = props.y, unit = "px" } - end - else - self.y = 0 - self.units.y = { value = 0, unit = "px" } - end - - self.z = props.z or 0 - else - -- Children in flex containers start at parent position but will be repositioned by layoutChildren - local baseX = self.parent.x - local baseY = self.parent.y - - if props.x then - if type(props.x) == "string" then - local value, unit = Units.parse(props.x) - self.units.x = { value = value, unit = unit } - local parentWidth = self.parent.width - local offsetX = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) - self.x = baseX + offsetX - else - -- Apply base scaling to pixel offsets - local scaledOffset = Gui.baseScale and (props.x * scaleX) or props.x - self.x = baseX + scaledOffset - self.units.x = { value = props.x, unit = "px" } - end - else - self.x = baseX - self.units.x = { value = 0, unit = "px" } - end - - if props.y then - if type(props.y) == "string" then - local value, unit = Units.parse(props.y) - self.units.y = { value = value, unit = unit } - local parentHeight = self.parent.height - local offsetY = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) - self.y = baseY + offsetY - else - -- Apply base scaling to pixel offsets - local scaledOffset = Gui.baseScale and (props.y * scaleY) or props.y - self.y = baseY + scaledOffset - self.units.y = { value = props.y, unit = "px" } - end - else - self.y = baseY - self.units.y = { value = 0, unit = "px" } - end - - self.z = props.z or self.parent.z or 0 - end - - -- Set textColor with priority: props > parent > theme text color > black - if props.textColor then - self.textColor = props.textColor - elseif self.parent.textColor then - self.textColor = self.parent.textColor - else - -- Try to get text color from theme - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.colors and themeToUse.colors.text then - self.textColor = themeToUse.colors.text - else - -- Fallback to black - self.textColor = Color.new(0, 0, 0, 1) - end - end - - props.parent:addChild(self) - end - - -- Handle positioning properties for ALL elements (with or without parent) - -- Handle top positioning with units - if props.top then - if type(props.top) == "string" then - local value, unit = Units.parse(props.top) - self.units.top = { value = value, unit = unit } - self.top = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) - else - self.top = props.top - self.units.top = { value = props.top, unit = "px" } - end - else - self.top = nil - self.units.top = nil - end - - -- Handle right positioning with units - if props.right then - if type(props.right) == "string" then - local value, unit = Units.parse(props.right) - self.units.right = { value = value, unit = unit } - self.right = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) - else - self.right = props.right - self.units.right = { value = props.right, unit = "px" } - end - else - self.right = nil - self.units.right = nil - end - - -- Handle bottom positioning with units - if props.bottom then - if type(props.bottom) == "string" then - local value, unit = Units.parse(props.bottom) - self.units.bottom = { value = value, unit = unit } - self.bottom = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) - else - self.bottom = props.bottom - self.units.bottom = { value = props.bottom, unit = "px" } - end - else - self.bottom = nil - self.units.bottom = nil - end - - -- Handle left positioning with units - if props.left then - if type(props.left) == "string" then - local value, unit = Units.parse(props.left) - self.units.left = { value = value, unit = unit } - self.left = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) - else - self.left = props.left - self.units.left = { value = props.left, unit = "px" } - end - else - self.left = nil - self.units.left = nil - end - - if self.positioning == Positioning.FLEX then - self.flexDirection = props.flexDirection or FlexDirection.HORIZONTAL - self.flexWrap = props.flexWrap or FlexWrap.NOWRAP - self.justifyContent = props.justifyContent or JustifyContent.FLEX_START - self.alignItems = props.alignItems or AlignItems.STRETCH - self.alignContent = props.alignContent or AlignContent.STRETCH - self.justifySelf = props.justifySelf or JustifySelf.AUTO - end - - -- Grid container properties - if self.positioning == Positioning.GRID then - self.gridRows = props.gridRows or 1 - self.gridColumns = props.gridColumns or 1 - self.alignItems = props.alignItems or AlignItems.STRETCH - - -- Handle columnGap and rowGap - if props.columnGap then - if type(props.columnGap) == "string" then - local value, unit = Units.parse(props.columnGap) - self.columnGap = Units.resolve(value, unit, viewportWidth, viewportHeight, self.width) - else - self.columnGap = props.columnGap - end - else - self.columnGap = 0 - end - - if props.rowGap then - if type(props.rowGap) == "string" then - local value, unit = Units.parse(props.rowGap) - self.rowGap = Units.resolve(value, unit, viewportWidth, viewportHeight, self.height) - else - self.rowGap = props.rowGap - end - else - self.rowGap = 0 - end - end - - self.alignSelf = props.alignSelf or AlignSelf.AUTO - - ---animation - self.transform = props.transform or {} - self.transition = props.transition or {} - - -- Overflow and scroll properties - self.overflow = props.overflow or "visible" - self.overflowX = props.overflowX - self.overflowY = props.overflowY - - -- Scrollbar configuration - self.scrollbarWidth = props.scrollbarWidth or 12 - self.scrollbarColor = props.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8) - self.scrollbarTrackColor = props.scrollbarTrackColor or Color.new(0.2, 0.2, 0.2, 0.5) - self.scrollbarRadius = props.scrollbarRadius or 6 - self.scrollbarPadding = props.scrollbarPadding or 2 - self.scrollSpeed = props.scrollSpeed or 20 - - -- Internal overflow state - self._overflowX = false - self._overflowY = false - self._contentWidth = 0 - self._contentHeight = 0 - - -- Scroll state - self._scrollX = 0 - self._scrollY = 0 - self._maxScrollX = 0 - self._maxScrollY = 0 - - -- Scrollbar interaction state - self._scrollbarHovered = false - self._scrollbarDragging = false - self._hoveredScrollbar = nil -- "vertical" or "horizontal" - self._scrollbarDragOffset = 0 -- Offset from thumb top when drag started - - return self -end - ---- Get element bounds (content box) ----@return { x:number, y:number, width:number, height:number } -function Element:getBounds() - return { x = self.x, y = self.y, width = self:getBorderBoxWidth(), height = self:getBorderBoxHeight() } -end - ---- Check if point is inside element bounds ---- @param x number ---- @param y number ---- @return boolean -function Element:contains(x, y) - local bounds = self:getBounds() - return bounds.x <= x and bounds.y <= y and bounds.x + bounds.width >= x and bounds.y + bounds.height >= y -end - ---- Get border-box width (including padding) ----@return number -function Element:getBorderBoxWidth() - return self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) -end - ---- Get border-box height (including padding) ----@return number -function Element:getBorderBoxHeight() - return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) -end - ---- Detect if content overflows container bounds -function Element:_detectOverflow() - -- Reset overflow state - self._overflowX = false - self._overflowY = false - self._contentWidth = self.width - self._contentHeight = self.height - - -- Skip detection if overflow is visible (no clipping needed) - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - if overflowX == "visible" and overflowY == "visible" then - return - end - - -- Calculate content bounds based on children - if #self.children == 0 then - return -- No children, no overflow - end - - local minX, minY = math.huge, math.huge - local maxX, maxY = -math.huge, -math.huge - - for _, child in ipairs(self.children) do - -- Skip absolutely positioned children (they don't contribute to overflow) - if not child._explicitlyAbsolute then - local childLeft = child.x - self.x - local childTop = child.y - self.y - local childRight = childLeft + child:getBorderBoxWidth() + child.margin.left + child.margin.right - local childBottom = childTop + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom - - minX = math.min(minX, childLeft) - minY = math.min(minY, childTop) - maxX = math.max(maxX, childRight) - maxY = math.max(maxY, childBottom) - end - end - - -- If no non-absolute children, no overflow - if minX == math.huge then - return - end - - -- Calculate content dimensions - self._contentWidth = math.max(0, maxX - minX) - self._contentHeight = math.max(0, maxY - minY) - - -- Detect overflow - local containerWidth = self.width - local containerHeight = self.height - - self._overflowX = self._contentWidth > containerWidth - self._overflowY = self._contentHeight > containerHeight - - -- Calculate maximum scroll bounds - self._maxScrollX = math.max(0, self._contentWidth - containerWidth) - self._maxScrollY = math.max(0, self._contentHeight - containerHeight) - - -- Clamp current scroll position to new bounds - self._scrollX = math.max(0, math.min(self._scrollX, self._maxScrollX)) - self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY)) -end - ---- Set scroll position with bounds clamping ----@param x number? -- X scroll position (nil to keep current) ----@param y number? -- Y scroll position (nil to keep current) -function Element:setScrollPosition(x, y) - if x ~= nil then - self._scrollX = math.max(0, math.min(x, self._maxScrollX)) - end - if y ~= nil then - self._scrollY = math.max(0, math.min(y, self._maxScrollY)) - end -end - ---- Calculate scrollbar dimensions and positions ----@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}} -function Element:_calculateScrollbarDimensions() - local result = { - vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, - horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, - } - - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - -- Vertical scrollbar - if self._overflowY and (overflowY == "scroll" or overflowY == "auto") then - result.vertical.visible = true - result.vertical.trackHeight = self.height - (self.scrollbarPadding * 2) - - -- Calculate thumb height based on content ratio - local contentRatio = self.height / math.max(self._contentHeight, self.height) - result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) - - -- Calculate thumb position based on scroll ratio - local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 - local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight - result.vertical.thumbY = maxThumbY * scrollRatio - elseif overflowY == "scroll" then - -- Always show scrollbar for "scroll" mode even without overflow - result.vertical.visible = true - result.vertical.trackHeight = self.height - (self.scrollbarPadding * 2) - result.vertical.thumbHeight = result.vertical.trackHeight - result.vertical.thumbY = 0 - end - - -- Horizontal scrollbar - if self._overflowX and (overflowX == "scroll" or overflowX == "auto") then - result.horizontal.visible = true - result.horizontal.trackWidth = self.width - (self.scrollbarPadding * 2) - - -- Calculate thumb width based on content ratio - local contentRatio = self.width / math.max(self._contentWidth, self.width) - result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) - - -- Calculate thumb position based on scroll ratio - local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 - local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth - result.horizontal.thumbX = maxThumbX * scrollRatio - elseif overflowX == "scroll" then - -- Always show scrollbar for "scroll" mode even without overflow - result.horizontal.visible = true - result.horizontal.trackWidth = self.width - (self.scrollbarPadding * 2) - result.horizontal.thumbWidth = result.horizontal.trackWidth - result.horizontal.thumbX = 0 - end - - return result -end - ---- Draw scrollbars ----@param dims table -- Scrollbar dimensions from _calculateScrollbarDimensions() -function Element:_drawScrollbars(dims) - local x, y = self.x, self.y - local w, h = self.width, self.height - - -- Determine thumb color based on state - local thumbColor = self.scrollbarColor - if self._scrollbarDragging then - -- Active state: brighter - thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a) - elseif self._scrollbarHovered then - -- Hover state: slightly brighter - thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a) - end - - -- Vertical scrollbar - if dims.vertical.visible then - local trackX = x + w - self.scrollbarWidth - self.scrollbarPadding + self.padding.left - local trackY = y + self.scrollbarPadding + self.padding.top - - -- Draw track - love.graphics.setColor(self.scrollbarTrackColor:toRGBA()) - love.graphics.rectangle("fill", trackX, trackY, self.scrollbarWidth, dims.vertical.trackHeight, self.scrollbarRadius) - - -- Draw thumb with state-based color - love.graphics.setColor(thumbColor:toRGBA()) - love.graphics.rectangle("fill", trackX, trackY + dims.vertical.thumbY, self.scrollbarWidth, dims.vertical.thumbHeight, self.scrollbarRadius) - end - - -- Horizontal scrollbar - if dims.horizontal.visible then - local trackX = x + self.scrollbarPadding + self.padding.left - local trackY = y + h - self.scrollbarWidth - self.scrollbarPadding + self.padding.top - - -- Draw track - love.graphics.setColor(self.scrollbarTrackColor:toRGBA()) - love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, self.scrollbarWidth, self.scrollbarRadius) - - -- Draw thumb with state-based color - love.graphics.setColor(thumbColor:toRGBA()) - love.graphics.rectangle("fill", trackX + dims.horizontal.thumbX, trackY, dims.horizontal.thumbWidth, self.scrollbarWidth, self.scrollbarRadius) - end - - -- Reset color - love.graphics.setColor(1, 1, 1, 1) -end - ---- Get scrollbar at mouse position ----@param mouseX number ----@param mouseY number ----@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"} -function Element:_getScrollbarAtPosition(mouseX, mouseY) - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then - return nil - end - - local dims = self:_calculateScrollbarDimensions() - local x, y = self.x, self.y - local w, h = self.width, self.height - - -- Check vertical scrollbar - if dims.vertical.visible then - local trackX = x + w - self.scrollbarWidth - self.scrollbarPadding + self.padding.left - local trackY = y + self.scrollbarPadding + self.padding.top - local trackW = self.scrollbarWidth - local trackH = dims.vertical.trackHeight - - if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then - -- Check if over thumb - local thumbY = trackY + dims.vertical.thumbY - local thumbH = dims.vertical.thumbHeight - if mouseY >= thumbY and mouseY <= thumbY + thumbH then - return { component = "vertical", region = "thumb" } - else - return { component = "vertical", region = "track" } - end - end - end - - -- Check horizontal scrollbar - if dims.horizontal.visible then - local trackX = x + self.scrollbarPadding + self.padding.left - local trackY = y + h - self.scrollbarWidth - self.scrollbarPadding + self.padding.top - local trackW = dims.horizontal.trackWidth - local trackH = self.scrollbarWidth - - if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then - -- Check if over thumb - local thumbX = trackX + dims.horizontal.thumbX - local thumbW = dims.horizontal.thumbWidth - if mouseX >= thumbX and mouseX <= thumbX + thumbW then - return { component = "horizontal", region = "thumb" } - else - return { component = "horizontal", region = "track" } - end - end - end - - return nil -end - ---- Handle scrollbar mouse press ----@param mouseX number ----@param mouseY number ----@param button number ----@return boolean -- True if event was consumed -function Element:_handleScrollbarPress(mouseX, mouseY, button) - if button ~= 1 then - return false - end -- Only left click - - local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY) - if not scrollbar then - return false - end - - if scrollbar.region == "thumb" then - -- Start dragging thumb - self._scrollbarDragging = true - self._hoveredScrollbar = scrollbar.component - local dims = self:_calculateScrollbarDimensions() - - if scrollbar.component == "vertical" then - local trackY = self.y + self.scrollbarPadding + self.padding.top - local thumbY = trackY + dims.vertical.thumbY - self._scrollbarDragOffset = mouseY - thumbY - elseif scrollbar.component == "horizontal" then - local trackX = self.x + self.scrollbarPadding + self.padding.left - local thumbX = trackX + dims.horizontal.thumbX - self._scrollbarDragOffset = mouseX - thumbX - end - - return true -- Event consumed - elseif scrollbar.region == "track" then - -- Click on track - jump to position - self:_scrollToTrackPosition(mouseX, mouseY, scrollbar.component) - return true - end - - return false -end - ---- Handle scrollbar drag ----@param mouseX number ----@param mouseY number ----@return boolean -- True if event was consumed -function Element:_handleScrollbarDrag(mouseX, mouseY) - if not self._scrollbarDragging then - return false - end - - local dims = self:_calculateScrollbarDimensions() - - if self._hoveredScrollbar == "vertical" then - local trackY = self.y + self.scrollbarPadding + self.padding.top - local trackH = dims.vertical.trackHeight - local thumbH = dims.vertical.thumbHeight - - -- Calculate new thumb position - local newThumbY = mouseY - self._scrollbarDragOffset - trackY - newThumbY = math.max(0, math.min(newThumbY, trackH - thumbH)) - - -- Convert thumb position to scroll position - local scrollRatio = (trackH - thumbH) > 0 and (newThumbY / (trackH - thumbH)) or 0 - local newScrollY = scrollRatio * self._maxScrollY - - self:setScrollPosition(nil, newScrollY) - return true - elseif self._hoveredScrollbar == "horizontal" then - local trackX = self.x + self.scrollbarPadding + self.padding.left - local trackW = dims.horizontal.trackWidth - local thumbW = dims.horizontal.thumbWidth - - -- Calculate new thumb position - local newThumbX = mouseX - self._scrollbarDragOffset - trackX - newThumbX = math.max(0, math.min(newThumbX, trackW - thumbW)) - - -- Convert thumb position to scroll position - local scrollRatio = (trackW - thumbW) > 0 and (newThumbX / (trackW - thumbW)) or 0 - local newScrollX = scrollRatio * self._maxScrollX - - self:setScrollPosition(newScrollX, nil) - return true - end - - return false -end - ---- Handle scrollbar release ----@param button number ----@return boolean -- True if event was consumed -function Element:_handleScrollbarRelease(button) - if button ~= 1 then - return false - end - - if self._scrollbarDragging then - self._scrollbarDragging = false - return true - end - - return false -end - ---- Scroll to track click position ----@param mouseX number ----@param mouseY number ----@param component string -- "vertical" or "horizontal" -function Element:_scrollToTrackPosition(mouseX, mouseY, component) - local dims = self:_calculateScrollbarDimensions() - - if component == "vertical" then - local trackY = self.y + self.scrollbarPadding + self.padding.top - local trackH = dims.vertical.trackHeight - local thumbH = dims.vertical.thumbHeight - - -- Calculate target thumb position (centered on click) - local targetThumbY = mouseY - trackY - (thumbH / 2) - targetThumbY = math.max(0, math.min(targetThumbY, trackH - thumbH)) - - -- Convert to scroll position - local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0 - local newScrollY = scrollRatio * self._maxScrollY - - self:setScrollPosition(nil, newScrollY) - elseif component == "horizontal" then - local trackX = self.x + self.scrollbarPadding + self.padding.left - local trackW = dims.horizontal.trackWidth - local thumbW = dims.horizontal.thumbWidth - - -- Calculate target thumb position (centered on click) - local targetThumbX = mouseX - trackX - (thumbW / 2) - targetThumbX = math.max(0, math.min(targetThumbX, trackW - thumbW)) - - -- Convert to scroll position - local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0 - local newScrollX = scrollRatio * self._maxScrollX - - self:setScrollPosition(newScrollX, nil) - end -end - ---- Handle mouse wheel scrolling ----@param x number -- Horizontal scroll amount ----@param y number -- Vertical scroll amount ----@return boolean -- True if scroll was handled -function Element:_handleWheelScroll(x, y) - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then - return false - end - - local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0 - local hasHorizontalOverflow = self._overflowX and self._maxScrollX > 0 - - local scrolled = false - - -- Vertical scrolling - if y ~= 0 and hasVerticalOverflow then - local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up - local newScrollY = self._scrollY + delta - self:setScrollPosition(nil, newScrollY) - scrolled = true - end - - -- Horizontal scrolling - if x ~= 0 and hasHorizontalOverflow then - local delta = -x * self.scrollSpeed - local newScrollX = self._scrollX + delta - self:setScrollPosition(newScrollX, nil) - scrolled = true - end - - return scrolled -end - ---- Get current scroll position ----@return number scrollX, number scrollY -function Element:getScrollPosition() - return self._scrollX, self._scrollY -end - ---- Get maximum scroll bounds ----@return number maxScrollX, number maxScrollY -function Element:getMaxScroll() - return self._maxScrollX, self._maxScrollY -end - ---- Get scroll percentage (0-1) ----@return number percentX, number percentY -function Element:getScrollPercentage() - local percentX = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 - local percentY = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 - return percentX, percentY -end - ---- Check if element has overflow ----@return boolean hasOverflowX, boolean hasOverflowY -function Element:hasOverflow() - return self._overflowX, self._overflowY -end - ---- Get content dimensions (including overflow) ----@return number contentWidth, number contentHeight -function Element:getContentSize() - return self._contentWidth, self._contentHeight -end - ---- Scroll by delta amount ----@param dx number? -- X delta (nil for no change) ----@param dy number? -- Y delta (nil for no change) -function Element:scrollBy(dx, dy) - if dx then - self._scrollX = math.max(0, math.min(self._scrollX + dx, self._maxScrollX)) - end - if dy then - self._scrollY = math.max(0, math.min(self._scrollY + dy, self._maxScrollY)) - end -end - ---- Scroll to top -function Element:scrollToTop() - self:setScrollPosition(nil, 0) -end - ---- Scroll to bottom -function Element:scrollToBottom() - self:setScrollPosition(nil, self._maxScrollY) -end - ---- Scroll to left -function Element:scrollToLeft() - self:setScrollPosition(0, nil) -end - ---- Scroll to right -function Element:scrollToRight() - self:setScrollPosition(self._maxScrollX, nil) -end - ---- Get the current state's scaled content padding ---- Returns the contentPadding for the current theme state, scaled to the element's size ----@return table|nil -- {left, top, right, bottom} or nil if no contentPadding -function Element:getScaledContentPadding() - if not self.themeComponent then - return nil - end - - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if not themeToUse or not themeToUse.components[self.themeComponent] then - return nil - end - - local component = themeToUse.components[self.themeComponent] - - -- Check for state-specific override - local state = self._themeState or "normal" - if state and state ~= "normal" and component.states and component.states[state] then - component = component.states[state] - end - - if not component._ninePatchData or not component._ninePatchData.contentPadding then - return nil - end - - local contentPadding = component._ninePatchData.contentPadding - - -- Scale contentPadding to match the actual rendered size - local atlasImage = component._loadedAtlas or themeToUse.atlas - if atlasImage and type(atlasImage) ~= "string" then - local originalWidth, originalHeight = atlasImage:getDimensions() - local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) - local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - local scaleX = borderBoxWidth / originalWidth - local scaleY = borderBoxHeight / originalHeight - - return { - left = contentPadding.left * scaleX, - top = contentPadding.top * scaleY, - right = contentPadding.right * scaleX, - bottom = contentPadding.bottom * scaleY, - } - else - -- Return unscaled values as fallback - return { - left = contentPadding.left, - top = contentPadding.top, - right = contentPadding.right, - bottom = contentPadding.bottom, - } - end -end - ---- Get or create blur instance for this element ----@return table? -- Blur instance or nil if no blur configured -function Element:getBlurInstance() - -- Determine quality from contentBlur or backdropBlur - local quality = 5 -- Default quality - if self.contentBlur and self.contentBlur.quality then - quality = self.contentBlur.quality - elseif self.backdropBlur and self.backdropBlur.quality then - quality = self.backdropBlur.quality - end - - -- Create blur instance if needed - if not self._blurInstance or self._blurInstance.quality ~= quality then - self._blurInstance = Blur.new(quality) - end - - return self._blurInstance -end - ---- Get available content width for children (accounting for 9-slice content padding) ---- This is the width that children should use when calculating percentage widths ----@return number -function Element:getAvailableContentWidth() - local availableWidth = self.width - - local scaledContentPadding = self:getScaledContentPadding() - if scaledContentPadding then - -- Check if the element is using the scaled 9-patch contentPadding as its padding - -- Allow small floating point differences (within 0.1 pixels) - local usingContentPaddingAsPadding = ( - math.abs(self.padding.left - scaledContentPadding.left) < 0.1 and math.abs(self.padding.right - scaledContentPadding.right) < 0.1 - ) - - if not usingContentPaddingAsPadding then - -- Element has explicit padding different from contentPadding - -- Subtract scaled contentPadding to get the area children should use - availableWidth = availableWidth - scaledContentPadding.left - scaledContentPadding.right - end - end - - return math.max(0, availableWidth) -end - ---- Get available content height for children (accounting for 9-slice content padding) ---- This is the height that children should use when calculating percentage heights ----@return number -function Element:getAvailableContentHeight() - local availableHeight = self.height - - local scaledContentPadding = self:getScaledContentPadding() - if scaledContentPadding then - -- Check if the element is using the scaled 9-patch contentPadding as its padding - -- Allow small floating point differences (within 0.1 pixels) - local usingContentPaddingAsPadding = ( - math.abs(self.padding.top - scaledContentPadding.top) < 0.1 and math.abs(self.padding.bottom - scaledContentPadding.bottom) < 0.1 - ) - - if not usingContentPaddingAsPadding then - -- Element has explicit padding different from contentPadding - -- Subtract scaled contentPadding to get the area children should use - availableHeight = availableHeight - scaledContentPadding.top - scaledContentPadding.bottom - end - end - - return math.max(0, availableHeight) -end - ---- Add child to element ----@param child Element -function Element:addChild(child) - child.parent = self - - -- Re-evaluate positioning now that we have a parent - -- If child was created without explicit positioning, inherit from parent - if child._originalPositioning == nil then - -- No explicit positioning was set during construction - if self.positioning == Positioning.FLEX or self.positioning == Positioning.GRID then - child.positioning = Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid - child._explicitlyAbsolute = false -- Participate in parent's layout - else - child.positioning = Positioning.RELATIVE - child._explicitlyAbsolute = false -- Default for relative/absolute containers - end - end - -- If child._originalPositioning is set, it means explicit positioning was provided - -- and _explicitlyAbsolute was already set correctly during construction - - table.insert(self.children, child) - - -- Only recalculate auto-sizing if the child participates in layout - -- (CSS: absolutely positioned children don't affect parent auto-sizing) - if not child._explicitlyAbsolute then - if self.autosizing.height then - local contentHeight = self:calculateAutoHeight() - -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content - self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom - self.height = contentHeight - end - if self.autosizing.width then - local contentWidth = self:calculateAutoWidth() - -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content - self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right - self.width = contentWidth - end - end - - self:layoutChildren() -end - ---- Apply positioning offsets (top, right, bottom, left) to an element --- @param element The element to apply offsets to -function Element:applyPositioningOffsets(element) - if not element then - return - end - - -- For CSS-style positioning, we need the parent's bounds - local parent = element.parent - if not parent then - return - end - - -- Only apply offsets to explicitly absolute children or children in relative/absolute containers - -- Flex/grid children ignore positioning offsets as they participate in layout - local isFlexChild = element.positioning == Positioning.FLEX - or element.positioning == Positioning.GRID - or (element.positioning == Positioning.ABSOLUTE and not element._explicitlyAbsolute) - - if not isFlexChild then - -- Apply absolute positioning for explicitly absolute children - -- Apply top offset (distance from parent's content box top edge) - if element.top then - element.y = parent.y + parent.padding.top + element.top - end - - -- Apply bottom offset (distance from parent's content box bottom edge) - -- BORDER-BOX MODEL: Use border-box dimensions for positioning - if element.bottom then - local elementBorderBoxHeight = element:getBorderBoxHeight() - element.y = parent.y + parent.padding.top + parent.height - element.bottom - elementBorderBoxHeight - end - - -- Apply left offset (distance from parent's content box left edge) - if element.left then - element.x = parent.x + parent.padding.left + element.left - end - - -- Apply right offset (distance from parent's content box right edge) - -- BORDER-BOX MODEL: Use border-box dimensions for positioning - if element.right then - local elementBorderBoxWidth = element:getBorderBoxWidth() - element.x = parent.x + parent.padding.left + parent.width - element.right - elementBorderBoxWidth - end - end -end - -function Element:layoutChildren() - if self.positioning == Positioning.ABSOLUTE or self.positioning == Positioning.RELATIVE then - -- Absolute/Relative positioned containers don't layout their children according to flex rules, - -- but they should still apply CSS positioning offsets to their children - for _, child in ipairs(self.children) do - if child.top or child.right or child.bottom or child.left then - self:applyPositioningOffsets(child) - end - end - return - end - - -- Handle grid layout - if self.positioning == Positioning.GRID then - Grid.layoutGridItems(self) - return - end - - local childCount = #self.children - - if childCount == 0 then - return - end - - -- Get flex children (children that participate in flex layout) - local flexChildren = {} - for _, child in ipairs(self.children) do - local isFlexChild = not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) - if isFlexChild then - table.insert(flexChildren, child) - end - end - - if #flexChildren == 0 then - return - end - - -- Calculate space reserved by absolutely positioned siblings with explicit positioning - local reservedMainStart = 0 -- Space reserved at the start of main axis (left for horizontal, top for vertical) - local reservedMainEnd = 0 -- Space reserved at the end of main axis (right for horizontal, bottom for vertical) - local reservedCrossStart = 0 -- Space reserved at the start of cross axis (top for horizontal, left for vertical) - local reservedCrossEnd = 0 -- Space reserved at the end of cross axis (bottom for horizontal, right for vertical) - - for _, child in ipairs(self.children) do - -- Only consider absolutely positioned children with explicit positioning - if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then - -- BORDER-BOX MODEL: Use border-box dimensions for space calculations - local childBorderBoxWidth = child:getBorderBoxWidth() - local childBorderBoxHeight = child:getBorderBoxHeight() - - if self.flexDirection == FlexDirection.HORIZONTAL then - -- Horizontal layout: main axis is X, cross axis is Y - -- Check for left positioning (reserves space at main axis start) - if child.left then - local spaceNeeded = child.left + childBorderBoxWidth - reservedMainStart = math.max(reservedMainStart, spaceNeeded) - end - -- Check for right positioning (reserves space at main axis end) - if child.right then - local spaceNeeded = child.right + childBorderBoxWidth - reservedMainEnd = math.max(reservedMainEnd, spaceNeeded) - end - -- Check for top positioning (reserves space at cross axis start) - if child.top then - local spaceNeeded = child.top + childBorderBoxHeight - reservedCrossStart = math.max(reservedCrossStart, spaceNeeded) - end - -- Check for bottom positioning (reserves space at cross axis end) - if child.bottom then - local spaceNeeded = child.bottom + childBorderBoxHeight - reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded) - end - else - -- Vertical layout: main axis is Y, cross axis is X - -- Check for top positioning (reserves space at main axis start) - if child.top then - local spaceNeeded = child.top + childBorderBoxHeight - reservedMainStart = math.max(reservedMainStart, spaceNeeded) - end - -- Check for bottom positioning (reserves space at main axis end) - if child.bottom then - local spaceNeeded = child.bottom + childBorderBoxHeight - reservedMainEnd = math.max(reservedMainEnd, spaceNeeded) - end - -- Check for left positioning (reserves space at cross axis start) - if child.left then - local spaceNeeded = child.left + childBorderBoxWidth - reservedCrossStart = math.max(reservedCrossStart, spaceNeeded) - end - -- Check for right positioning (reserves space at cross axis end) - if child.right then - local spaceNeeded = child.right + childBorderBoxWidth - reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded) - end - end - end - end - - -- Calculate available space (accounting for padding and reserved space) - -- BORDER-BOX MODEL: self.width and self.height are already content dimensions (padding subtracted) - local availableMainSize = 0 - local availableCrossSize = 0 - if self.flexDirection == FlexDirection.HORIZONTAL then - availableMainSize = self.width - reservedMainStart - reservedMainEnd - availableCrossSize = self.height - reservedCrossStart - reservedCrossEnd - else - availableMainSize = self.height - reservedMainStart - reservedMainEnd - availableCrossSize = self.width - reservedCrossStart - reservedCrossEnd - end - - -- Handle flex wrap: create lines of children - local lines = {} - - if self.flexWrap == FlexWrap.NOWRAP then - -- All children go on one line - lines[1] = flexChildren - else - -- Wrap children into multiple lines - local currentLine = {} - local currentLineSize = 0 - - for _, child in ipairs(flexChildren) do - -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations - -- Include margins in size calculations - local childMainSize = 0 - local childMainMargin = 0 - if self.flexDirection == FlexDirection.HORIZONTAL then - childMainSize = child:getBorderBoxWidth() - childMainMargin = child.margin.left + child.margin.right - else - childMainSize = child:getBorderBoxHeight() - childMainMargin = child.margin.top + child.margin.bottom - end - local childTotalMainSize = childMainSize + childMainMargin - - -- Check if adding this child would exceed the available space - local lineSpacing = #currentLine > 0 and self.gap or 0 - if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableMainSize then - -- Start a new line - if #currentLine > 0 then - table.insert(lines, currentLine) - end - currentLine = { child } - currentLineSize = childTotalMainSize - else - -- Add to current line - table.insert(currentLine, child) - currentLineSize = currentLineSize + lineSpacing + childTotalMainSize - end - end - - -- Add the last line if it has children - if #currentLine > 0 then - table.insert(lines, currentLine) - end - - -- Handle wrap-reverse: reverse the order of lines - if self.flexWrap == FlexWrap.WRAP_REVERSE then - local reversedLines = {} - for i = #lines, 1, -1 do - table.insert(reversedLines, lines[i]) - end - lines = reversedLines - end - end - - -- Calculate line positions and heights (including child padding) - local lineHeights = {} - local totalLinesHeight = 0 - - for lineIndex, line in ipairs(lines) do - local maxCrossSize = 0 - for _, child in ipairs(line) do - -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations - -- Include margins in cross-axis size calculations - local childCrossSize = 0 - local childCrossMargin = 0 - if self.flexDirection == FlexDirection.HORIZONTAL then - childCrossSize = child:getBorderBoxHeight() - childCrossMargin = child.margin.top + child.margin.bottom - else - childCrossSize = child:getBorderBoxWidth() - childCrossMargin = child.margin.left + child.margin.right - end - local childTotalCrossSize = childCrossSize + childCrossMargin - maxCrossSize = math.max(maxCrossSize, childTotalCrossSize) - end - lineHeights[lineIndex] = maxCrossSize - totalLinesHeight = totalLinesHeight + maxCrossSize - end - - -- Account for gaps between lines - local lineGaps = math.max(0, #lines - 1) * self.gap - totalLinesHeight = totalLinesHeight + lineGaps - - -- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size - if #lines == 1 then - if self.alignItems == AlignItems.STRETCH or self.alignItems == AlignItems.CENTER or self.alignItems == AlignItems.FLEX_END then - -- STRETCH, CENTER, and FLEX_END should use full available cross size - lineHeights[1] = availableCrossSize - totalLinesHeight = availableCrossSize - end - -- CENTER and FLEX_END should preserve natural child dimensions - -- and only affect positioning within the available space - end - - -- Calculate starting position for lines based on alignContent - local lineStartPos = 0 - local lineSpacing = self.gap - local freeLineSpace = availableCrossSize - totalLinesHeight - - -- Apply AlignContent logic for both single and multiple lines - if self.alignContent == AlignContent.FLEX_START then - lineStartPos = 0 - elseif self.alignContent == AlignContent.CENTER then - lineStartPos = freeLineSpace / 2 - elseif self.alignContent == AlignContent.FLEX_END then - lineStartPos = freeLineSpace - elseif self.alignContent == AlignContent.SPACE_BETWEEN then - lineStartPos = 0 - if #lines > 1 then - lineSpacing = self.gap + (freeLineSpace / (#lines - 1)) - end - elseif self.alignContent == AlignContent.SPACE_AROUND then - local spaceAroundEach = freeLineSpace / #lines - lineStartPos = spaceAroundEach / 2 - lineSpacing = self.gap + spaceAroundEach - elseif self.alignContent == AlignContent.STRETCH then - lineStartPos = 0 - if #lines > 1 and freeLineSpace > 0 then - lineSpacing = self.gap + (freeLineSpace / #lines) - -- Distribute extra space to line heights (only if positive) - local extraPerLine = freeLineSpace / #lines - for i = 1, #lineHeights do - lineHeights[i] = lineHeights[i] + extraPerLine - end - end - end - - -- Position children within each line - local currentCrossPos = lineStartPos - - for lineIndex, line in ipairs(lines) do - local lineHeight = lineHeights[lineIndex] - - -- Calculate total size of children in this line (including padding and margins) - -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations - local totalChildrenSize = 0 - for _, child in ipairs(line) do - if self.flexDirection == FlexDirection.HORIZONTAL then - totalChildrenSize = totalChildrenSize + child:getBorderBoxWidth() + child.margin.left + child.margin.right - else - totalChildrenSize = totalChildrenSize + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom - end - end - - local totalGapSize = math.max(0, #line - 1) * self.gap - local totalContentSize = totalChildrenSize + totalGapSize - local freeSpace = availableMainSize - totalContentSize - - -- Calculate initial position and spacing based on justifyContent - local startPos = 0 - local itemSpacing = self.gap - - if self.justifyContent == JustifyContent.FLEX_START then - startPos = 0 - elseif self.justifyContent == JustifyContent.CENTER then - startPos = freeSpace / 2 - elseif self.justifyContent == JustifyContent.FLEX_END then - startPos = freeSpace - elseif self.justifyContent == JustifyContent.SPACE_BETWEEN then - startPos = 0 - if #line > 1 then - itemSpacing = self.gap + (freeSpace / (#line - 1)) - end - elseif self.justifyContent == JustifyContent.SPACE_AROUND then - local spaceAroundEach = freeSpace / #line - startPos = spaceAroundEach / 2 - itemSpacing = self.gap + spaceAroundEach - elseif self.justifyContent == JustifyContent.SPACE_EVENLY then - local spaceBetween = freeSpace / (#line + 1) - startPos = spaceBetween - itemSpacing = self.gap + spaceBetween - end - - -- Position children in this line - local currentMainPos = startPos - - for _, child in ipairs(line) do - -- Determine effective cross-axis alignment - local effectiveAlign = child.alignSelf - if effectiveAlign == nil or effectiveAlign == AlignSelf.AUTO then - effectiveAlign = self.alignItems - end - - if self.flexDirection == FlexDirection.HORIZONTAL then - -- Horizontal layout: main axis is X, cross axis is Y - -- Position child at border box (x, y represents top-left including padding) - -- Add reservedMainStart and left margin to account for absolutely positioned siblings and margins - child.x = self.x + self.padding.left + reservedMainStart + currentMainPos + child.margin.left - - -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations - local childBorderBoxHeight = child:getBorderBoxHeight() - local childTotalCrossSize = childBorderBoxHeight + child.margin.top + child.margin.bottom - - if effectiveAlign == AlignItems.FLEX_START then - child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + child.margin.top - elseif effectiveAlign == AlignItems.CENTER then - child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.top - elseif effectiveAlign == AlignItems.FLEX_END then - child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.top - elseif effectiveAlign == AlignItems.STRETCH then - -- STRETCH: Only apply if height was not explicitly set - if child.autosizing and child.autosizing.height then - -- STRETCH: Set border-box height to lineHeight minus margins, content area shrinks to fit - local availableHeight = lineHeight - child.margin.top - child.margin.bottom - child._borderBoxHeight = availableHeight - child.height = math.max(0, availableHeight - child.padding.top - child.padding.bottom) - end - child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + child.margin.top - end - - -- Apply positioning offsets (top, right, bottom, left) - self:applyPositioningOffsets(child) - - -- If child has children, re-layout them after position change - if #child.children > 0 then - child:layoutChildren() - end - - -- Advance position by child's border-box width plus margins - currentMainPos = currentMainPos + child:getBorderBoxWidth() + child.margin.left + child.margin.right + itemSpacing - else - -- Vertical layout: main axis is Y, cross axis is X - -- Position child at border box (x, y represents top-left including padding) - -- Add reservedMainStart and top margin to account for absolutely positioned siblings and margins - child.y = self.y + self.padding.top + reservedMainStart + currentMainPos + child.margin.top - - -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations - local childBorderBoxWidth = child:getBorderBoxWidth() - local childTotalCrossSize = childBorderBoxWidth + child.margin.left + child.margin.right - - if effectiveAlign == AlignItems.FLEX_START then - child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + child.margin.left - elseif effectiveAlign == AlignItems.CENTER then - child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.left - elseif effectiveAlign == AlignItems.FLEX_END then - child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.left - elseif effectiveAlign == AlignItems.STRETCH then - -- STRETCH: Only apply if width was not explicitly set - if child.autosizing and child.autosizing.width then - -- STRETCH: Set border-box width to lineHeight minus margins, content area shrinks to fit - local availableWidth = lineHeight - child.margin.left - child.margin.right - child._borderBoxWidth = availableWidth - child.width = math.max(0, availableWidth - child.padding.left - child.padding.right) - end - child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + child.margin.left - end - - -- Apply positioning offsets (top, right, bottom, left) - self:applyPositioningOffsets(child) - - -- If child has children, re-layout them after position change - if #child.children > 0 then - child:layoutChildren() - end - - -- Advance position by child's border-box height plus margins - currentMainPos = currentMainPos + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + itemSpacing - end - end - - -- Move to next line position - currentCrossPos = currentCrossPos + lineHeight + lineSpacing - end - - -- Position explicitly absolute children after flex layout - for _, child in ipairs(self.children) do - if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then - -- Apply positioning offsets (top, right, bottom, left) - self:applyPositioningOffsets(child) - - -- If child has children, layout them after position change - if #child.children > 0 then - child:layoutChildren() - end - end - end - - -- Detect overflow after children are laid out - self:_detectOverflow() -end - ---- Destroy element and its children -function Element:destroy() - -- Remove from global elements list - for i, win in ipairs(Gui.topElements) do - if win == self then - table.remove(Gui.topElements, i) - break - end - end - - if self.parent then - for i, child in ipairs(self.parent.children) do - if child == self then - table.remove(self.parent.children, i) - break - end - end - self.parent = nil - end - - -- Destroy all children - for _, child in ipairs(self.children) do - child:destroy() - end - - -- Clear children table - self.children = {} - - -- Clear parent reference - if self.parent then - self.parent = nil - end - - -- Clear animation reference - self.animation = nil - - -- Clear callback to prevent closure leaks - self.callback = nil -end - ---- Draw element and its children -function Element:draw(backdropCanvas) - -- Early exit if element is invisible (optimization) - if self.opacity <= 0 then - return - end - - -- Handle opacity during animation - local drawBackgroundColor = self.backgroundColor - if self.animation then - local anim = self.animation:interpolate() - if anim.opacity then - drawBackgroundColor = Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity) - end - end - - -- Cache border box dimensions for this draw call (optimization) - local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) - local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - - -- 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() - if blurInstance then - Blur.applyBackdrop(blurInstance, self.backdropBlur.intensity, self.x, self.y, borderBoxWidth, borderBoxHeight, backdropCanvas) - end - end - - -- LAYER 1: Draw backgroundColor first (behind everything) - -- Apply opacity to all drawing operations - -- (x, y) represents border box, so draw background from (x, y) - -- BORDER-BOX MODEL: Use stored border-box dimensions for drawing - local backgroundWithOpacity = Color.new(drawBackgroundColor.r, drawBackgroundColor.g, drawBackgroundColor.b, drawBackgroundColor.a * self.opacity) - love.graphics.setColor(backgroundWithOpacity:toRGBA()) - RoundedRect.draw("fill", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) - - -- LAYER 1.5: Draw image on top of backgroundColor (if image exists) - if self._loadedImage then - -- Calculate image bounds (content area - respects padding) - local imageX = self.x + self.padding.left - local imageY = self.y + self.padding.top - local imageWidth = self.width - local imageHeight = self.height - - -- Combine element opacity with imageOpacity - local finalOpacity = self.opacity * self.imageOpacity - - -- Apply cornerRadius clipping if set - local hasCornerRadius = self.cornerRadius.topLeft > 0 - or self.cornerRadius.topRight > 0 - or self.cornerRadius.bottomLeft > 0 - or self.cornerRadius.bottomRight > 0 - - if hasCornerRadius then - -- Use stencil to clip image to rounded corners - love.graphics.stencil(function() - RoundedRect.draw("fill", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) - end, "replace", 1) - love.graphics.setStencilTest("greater", 0) - end - - -- Draw the image - ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity) - - -- Clear stencil if it was used - if hasCornerRadius then - love.graphics.setStencilTest() - end - end - - -- LAYER 2: Draw theme on top of backgroundColor (if theme exists) - if self.themeComponent then - -- Get the theme to use - local themeToUse = nil - if self.theme then - -- Element specifies a specific theme - load it if needed - if themes[self.theme] then - themeToUse = themes[self.theme] - else - -- Try to load the theme - pcall(function() - Theme.load(self.theme) - end) - themeToUse = themes[self.theme] - end - else - -- Use active theme - themeToUse = Theme.getActive() - end - - if themeToUse then - -- Get the component from the theme - local component = themeToUse.components[self.themeComponent] - if component then - -- Check for state-specific override - local state = self._themeState - if state and component.states and component.states[state] then - component = component.states[state] - end - - -- Use component-specific atlas if available, otherwise use theme atlas - local atlasToUse = component._loadedAtlas or themeToUse.atlas - - if atlasToUse and component.regions then - -- Validate component has required structure - local hasAllRegions = component.regions.topLeft - and component.regions.topCenter - and component.regions.topRight - and component.regions.middleLeft - and component.regions.middleCenter - and component.regions.middleRight - and component.regions.bottomLeft - and component.regions.bottomCenter - and component.regions.bottomRight - if hasAllRegions then - -- Calculate border-box dimensions (content + padding) - local borderBoxWidth = self.width + self.padding.left + self.padding.right - local borderBoxHeight = self.height + self.padding.top + self.padding.bottom - -- Pass element-level overrides for scaleCorners and scalingAlgorithm - NineSlice.draw(component, atlasToUse, self.x, self.y, borderBoxWidth, borderBoxHeight, self.opacity, self.scaleCorners, self.scalingAlgorithm) - else - -- Silently skip drawing if component structure is invalid - end - end - else - print("[FlexLove] Component not found: " .. self.themeComponent .. " in theme: " .. themeToUse.name) - end - else - print("[FlexLove] No theme available for themeComponent: " .. self.themeComponent) - end - end - - -- LAYER 3: Draw borders on top of theme (always render if specified) - local borderColorWithOpacity = Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity) - love.graphics.setColor(borderColorWithOpacity:toRGBA()) - - -- Check if all borders are enabled - local allBorders = self.border.top and self.border.bottom and self.border.left and self.border.right - - if allBorders then - -- Draw complete rounded rectangle border - RoundedRect.draw("line", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) - else - -- Draw individual borders (without rounded corners for partial borders) - if self.border.top then - love.graphics.line(self.x, self.y, self.x + borderBoxWidth, self.y) - end - if self.border.bottom then - love.graphics.line(self.x, self.y + borderBoxHeight, self.x + borderBoxWidth, self.y + borderBoxHeight) - end - if self.border.left then - love.graphics.line(self.x, self.y, self.x, self.y + borderBoxHeight) - end - if self.border.right then - love.graphics.line(self.x + borderBoxWidth, self.y, self.x + borderBoxWidth, self.y + borderBoxHeight) - end - end - - -- Draw element text if present - if self.text then - local textColorWithOpacity = Color.new(self.textColor.r, self.textColor.g, self.textColor.b, self.textColor.a * self.opacity) - love.graphics.setColor(textColorWithOpacity:toRGBA()) - - local origFont = love.graphics.getFont() - if self.textSize then - -- Resolve font path from font family - local fontPath = nil - if self.fontFamily then - -- Check if fontFamily is a theme font name - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then - fontPath = themeToUse.fonts[self.fontFamily] - else - -- Treat as direct path to font file - fontPath = self.fontFamily - end - elseif self.themeComponent then - -- If using themeComponent but no fontFamily specified, check for default font in theme - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts.default then - fontPath = themeToUse.fonts.default - end - end - - -- Use cached font instead of creating new one every frame - local font = FONT_CACHE.get(self.textSize, fontPath) - love.graphics.setFont(font) - end - local font = love.graphics.getFont() - local textWidth = font:getWidth(self.text) - local textHeight = font:getHeight() - local tx, ty - - -- Text is drawn in the content box (inside padding) - -- For 9-slice components, use contentPadding if available - local textPaddingLeft = self.padding.left - local textPaddingTop = self.padding.top - local textAreaWidth = self.width - local textAreaHeight = self.height - - -- Check if we should use 9-slice contentPadding for text positioning - local scaledContentPadding = self:getScaledContentPadding() - if scaledContentPadding then - local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) - local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - - textPaddingLeft = scaledContentPadding.left - textPaddingTop = scaledContentPadding.top - textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right - textAreaHeight = borderBoxHeight - scaledContentPadding.top - scaledContentPadding.bottom - end - - local contentX = self.x + textPaddingLeft - local contentY = self.y + textPaddingTop - - -- Check if text wrapping is enabled - if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then - -- Use printf for wrapped text - local align = "left" - if self.textAlign == TextAlign.CENTER then - align = "center" - elseif self.textAlign == TextAlign.END then - align = "right" - elseif self.textAlign == TextAlign.JUSTIFY then - align = "justify" - end - - tx = contentX - ty = contentY - - -- Use printf with the available width for wrapping - love.graphics.printf(self.text, tx, ty, textAreaWidth, align) - else - -- Use regular print for non-wrapped text - if self.textAlign == TextAlign.START then - tx = contentX - ty = contentY - elseif self.textAlign == TextAlign.CENTER then - tx = contentX + (textAreaWidth - textWidth) / 2 - ty = contentY + (textAreaHeight - textHeight) / 2 - elseif self.textAlign == TextAlign.END then - tx = contentX + textAreaWidth - textWidth - 10 - ty = contentY + textAreaHeight - textHeight - 10 - elseif self.textAlign == TextAlign.JUSTIFY then - --- need to figure out spreading - tx = contentX - ty = contentY - end - love.graphics.print(self.text, tx, ty) - end - if self.textSize then - love.graphics.setFont(origFont) - end - end - - -- Draw visual feedback when element is pressed (if it has a callback and highlight is not disabled) - if self.callback and not self.disableHighlight then - -- Check if any button is pressed - local anyPressed = false - for _, pressed in pairs(self._pressed) do - if pressed then - anyPressed = true - break - end - end - if anyPressed then - -- BORDER-BOX MODEL: Use stored border-box dimensions for drawing - local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) - local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * self.opacity) -- Semi-transparent gray for pressed state with opacity - RoundedRect.draw("fill", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) - end - end - - -- Sort children by z-index before drawing - local sortedChildren = {} - for _, child in ipairs(self.children) do - table.insert(sortedChildren, child) - end - table.sort(sortedChildren, function(a, b) - return a.z < b.z - end) - - -- Check if we need to clip children to rounded corners - local hasRoundedCorners = self.cornerRadius.topLeft > 0 - or self.cornerRadius.topRight > 0 - or self.cornerRadius.bottomLeft > 0 - or self.cornerRadius.bottomRight > 0 - - -- Helper function to draw children (with or without clipping) - local function drawChildren() - -- Determine if we need overflow clipping - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible") and (overflowX ~= nil or overflowY ~= nil) - - -- Apply scroll offset if overflow is not visible - local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0) - - if hasScrollOffset then - love.graphics.push() - love.graphics.translate(-self._scrollX, -self._scrollY) - end - - if hasRoundedCorners and #sortedChildren > 0 then - -- Use stencil to clip children to rounded rectangle - -- BORDER-BOX MODEL: Use stored border-box dimensions for clipping - local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) - local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - local stencilFunc = RoundedRect.stencilFunction(self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) - - love.graphics.stencil(stencilFunc, "replace", 1) - love.graphics.setStencilTest("greater", 0) - - for _, child in ipairs(sortedChildren) do - child:draw(backdropCanvas) - end - - love.graphics.setStencilTest() - elseif needsOverflowClipping and #sortedChildren > 0 then - -- Clip content for overflow hidden/scroll/auto without rounded corners - local contentX = self.x + self.padding.left - local contentY = self.y + self.padding.top - local contentWidth = self.width - local contentHeight = self.height - - love.graphics.setScissor(contentX, contentY, contentWidth, contentHeight) - - for _, child in ipairs(sortedChildren) do - child:draw(backdropCanvas) - end - - love.graphics.setScissor() - else - -- No clipping needed - for _, child in ipairs(sortedChildren) do - child:draw(backdropCanvas) - end - end - - if hasScrollOffset then - love.graphics.pop() - end - end - - -- Apply content blur if configured - if self.contentBlur and self.contentBlur.intensity > 0 and #sortedChildren > 0 then - local blurInstance = self:getBlurInstance() - if blurInstance then - Blur.applyToRegion(blurInstance, self.contentBlur.intensity, self.x, self.y, borderBoxWidth, borderBoxHeight, drawChildren) - else - drawChildren() - end - else - drawChildren() - end - - -- Draw scrollbars if overflow is scroll or auto - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then - local scrollbarDims = self:_calculateScrollbarDimensions() - if scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible then - self:_drawScrollbars(scrollbarDims) - end - end -end - ---- Update element (propagate to children) ----@param dt number -function Element:update(dt) - for _, child in ipairs(self.children) do - child:update(dt) - end - - -- Update cursor blink timer (only if editable and focused) - if self.editable and self._focused then - self._cursorBlinkTimer = self._cursorBlinkTimer + dt - if self._cursorBlinkTimer >= self.cursorBlinkRate then - self._cursorBlinkTimer = 0 - self._cursorVisible = not self._cursorVisible - end - end - - -- Update animation if exists - if self.animation then - local finished = self.animation:update(dt) - if finished then - self.animation = nil -- remove finished animation - else - -- Apply animation interpolation during update - local anim = self.animation:interpolate() - 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.backgroundColor.a = anim.opacity - end - end - end - - -- Handle scrollbar hover detection - local mx, my = love.mouse.getPosition() - local scrollbar = self:_getScrollbarAtPosition(mx, my) - local wasHovered = self._scrollbarHovered - if scrollbar then - self._scrollbarHovered = true - self._hoveredScrollbar = scrollbar.component - else - if not self._scrollbarDragging then - self._scrollbarHovered = false - self._hoveredScrollbar = nil - end - end - - -- Handle scrollbar dragging - if self._scrollbarDragging and love.mouse.isDown(1) then - self:_handleScrollbarDrag(mx, my) - elseif self._scrollbarDragging then - -- Mouse button released - self._scrollbarDragging = false - end - - -- Handle click detection for element with enhanced event system - if self.callback or self.themeComponent then - -- Clickable area is the border box (x, y already includes padding) - -- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection - local bx = self.x - local by = self.y - local bw = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) - local bh = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) - local isHovering = mx >= bx and mx <= bx + bw and my >= by and my <= by + bh - - -- Update theme state based on interaction - if self.themeComponent then - -- Disabled state takes priority - if self.disabled then - self._themeState = "disabled" - -- Active state (for inputs when focused/typing) - elseif self.active then - self._themeState = "active" - elseif isHovering then - -- Check if any button is pressed - local anyPressed = false - for _, pressed in pairs(self._pressed) do - if pressed then - anyPressed = true - break - end - end - - if anyPressed then - self._themeState = "pressed" - else - self._themeState = "hover" - end - else - self._themeState = "normal" - end - end - - -- Only process button events if callback exists, element is not disabled, - -- and this is the topmost element at the mouse position (z-index ordering) - local isActiveElement = (Gui._activeEventElement == nil or Gui._activeEventElement == self) - if self.callback and not self.disabled and isActiveElement then - -- Check all three mouse buttons - local buttons = { 1, 2, 3 } -- left, right, middle - - for _, button in ipairs(buttons) do - if isHovering then - if love.mouse.isDown(button) then - -- Button is pressed down - if not self._pressed[button] then - -- Check if press is on scrollbar first - if button == 1 and self:_handleScrollbarPress(mx, my, button) then - -- Scrollbar consumed the event, mark as pressed to prevent callback - self._pressed[button] = true - else - -- Just pressed - fire press event and record drag start position - local modifiers = getModifiers() - local pressEvent = InputEvent.new({ - type = "press", - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = 1, - }) - self.callback(self, pressEvent) - self._pressed[button] = true - end - - -- Record drag start position per button - self._dragStartX[button] = mx - self._dragStartY[button] = my - self._lastMouseX[button] = mx - self._lastMouseY[button] = my - else - -- Button is still pressed - check for mouse movement (drag) - local lastX = self._lastMouseX[button] or mx - local lastY = self._lastMouseY[button] or my - - if lastX ~= mx or lastY ~= my then - -- Mouse has moved - fire drag event - local modifiers = getModifiers() - local dx = mx - self._dragStartX[button] - local dy = my - self._dragStartY[button] - - local dragEvent = InputEvent.new({ - type = "drag", - button = button, - x = mx, - y = my, - dx = dx, - dy = dy, - modifiers = modifiers, - clickCount = 1, - }) - self.callback(self, dragEvent) - - -- Update last known position for this button - self._lastMouseX[button] = mx - self._lastMouseY[button] = my - end - end - elseif self._pressed[button] then - -- Button was just released - fire click event - local currentTime = love.timer.getTime() - local modifiers = getModifiers() - - -- Determine click count (double-click detection) - local clickCount = 1 - local doubleClickThreshold = 0.3 -- 300ms for double-click - - if self._lastClickTime and self._lastClickButton == button and (currentTime - self._lastClickTime) < doubleClickThreshold then - clickCount = self._clickCount + 1 - else - clickCount = 1 - end - - self._clickCount = clickCount - self._lastClickTime = currentTime - self._lastClickButton = button - - -- Determine event type based on button - local eventType = "click" - if button == 2 then - eventType = "rightclick" - elseif button == 3 then - eventType = "middleclick" - end - - local clickEvent = InputEvent.new({ - type = eventType, - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = clickCount, - }) - - self.callback(self, clickEvent) - self._pressed[button] = false - - -- Clean up drag tracking - self._dragStartX[button] = nil - self._dragStartY[button] = nil - - -- Focus editable elements on left click - if button == 1 and self.editable then - self:focus() - end - - -- Fire release event - local releaseEvent = InputEvent.new({ - type = "release", - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = clickCount, - }) - self.callback(self, releaseEvent) - end - else - -- Mouse left the element - reset pressed state and drag tracking - if self._pressed[button] then - self._pressed[button] = false - self._dragStartX[button] = nil - self._dragStartY[button] = nil - end - end - end - end -- end if self.callback - - -- Handle touch events (maintain backward compatibility) - if self.callback then - local touches = love.touch.getTouches() - for _, id in ipairs(touches) do - local tx, ty = love.touch.getPosition(id) - if tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh then - self._touchPressed[id] = true - elseif self._touchPressed[id] then - -- Create touch event (treat as left click) - local touchEvent = InputEvent.new({ - type = "click", - button = 1, - x = tx, - y = ty, - modifiers = getModifiers(), - clickCount = 1, - }) - self.callback(self, touchEvent) - self._touchPressed[id] = false - end - end - end - end -end - ---- Recalculate units based on new viewport dimensions (for vw, vh, % units) ----@param newViewportWidth number ----@param newViewportHeight number -function Element:recalculateUnits(newViewportWidth, newViewportHeight) - -- Get updated scale factors - local scaleX, scaleY = Gui.getScaleFactors() - - -- Recalculate border-box width if using viewport or percentage units (skip auto-sized) - -- Store in _borderBoxWidth temporarily, will calculate content width after padding is resolved - if self.units.width.unit ~= "px" and self.units.width.unit ~= "auto" then - local parentWidth = self.parent and self.parent.width or newViewportWidth - self._borderBoxWidth = Units.resolve(self.units.width.value, self.units.width.unit, newViewportWidth, newViewportHeight, parentWidth) - elseif self.units.width.unit == "px" and self.units.width.value and Gui.baseScale then - -- Reapply base scaling to pixel widths (border-box) - self._borderBoxWidth = self.units.width.value * scaleX - end - - -- Recalculate border-box height if using viewport or percentage units (skip auto-sized) - -- Store in _borderBoxHeight temporarily, will calculate content height after padding is resolved - if self.units.height.unit ~= "px" and self.units.height.unit ~= "auto" then - local parentHeight = self.parent and self.parent.height or newViewportHeight - self._borderBoxHeight = Units.resolve(self.units.height.value, self.units.height.unit, newViewportWidth, newViewportHeight, parentHeight) - elseif self.units.height.unit == "px" and self.units.height.value and Gui.baseScale then - -- Reapply base scaling to pixel heights (border-box) - self._borderBoxHeight = self.units.height.value * scaleY - end - - -- Recalculate position if using viewport or percentage units - if self.units.x.unit ~= "px" then - local parentWidth = self.parent and self.parent.width or newViewportWidth - local baseX = self.parent and self.parent.x or 0 - local offsetX = Units.resolve(self.units.x.value, self.units.x.unit, newViewportWidth, newViewportHeight, parentWidth) - self.x = baseX + offsetX - else - -- For pixel units, update position relative to parent's new position (with base scaling) - if self.parent then - local baseX = self.parent.x - local scaledOffset = Gui.baseScale and (self.units.x.value * scaleX) or self.units.x.value - self.x = baseX + scaledOffset - elseif Gui.baseScale then - -- Top-level element with pixel position - apply base scaling - self.x = self.units.x.value * scaleX - end - end - - if self.units.y.unit ~= "px" then - local parentHeight = self.parent and self.parent.height or newViewportHeight - local baseY = self.parent and self.parent.y or 0 - local offsetY = Units.resolve(self.units.y.value, self.units.y.unit, newViewportWidth, newViewportHeight, parentHeight) - self.y = baseY + offsetY - else - -- For pixel units, update position relative to parent's new position (with base scaling) - if self.parent then - local baseY = self.parent.y - local scaledOffset = Gui.baseScale and (self.units.y.value * scaleY) or self.units.y.value - self.y = baseY + scaledOffset - elseif Gui.baseScale then - -- Top-level element with pixel position - apply base scaling - self.y = self.units.y.value * scaleY - end - end - - -- Recalculate textSize if auto-scaling is enabled or using viewport/element-relative units - if self.autoScaleText and self.units.textSize.value then - local unit = self.units.textSize.unit - local value = self.units.textSize.value - - if unit == "px" and Gui.baseScale then - -- With base scaling: scale pixel values relative to base resolution - self.textSize = value * scaleY - elseif unit == "px" then - -- Without base scaling but auto-scaling enabled: text doesn't scale - self.textSize = value - elseif unit == "%" or unit == "vh" then - -- Percentage and vh are relative to viewport height - self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportHeight) - elseif unit == "vw" then - -- vw is relative to viewport width - self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportWidth) - elseif unit == "ew" then - -- Element width relative - self.textSize = (value / 100) * self.width - elseif unit == "eh" then - -- Element height relative - self.textSize = (value / 100) * self.height - else - self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, nil) - end - - -- Apply min/max constraints (with base scaling) - local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize) - local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) - - if minSize and self.textSize < minSize then - self.textSize = minSize - end - if maxSize and self.textSize > maxSize then - self.textSize = maxSize - end - - -- Protect against too-small text sizes (minimum 1px) - if self.textSize < 1 then - self.textSize = 1 -- Minimum 1px - end - elseif self.units.textSize.unit == "px" and self.units.textSize.value and Gui.baseScale then - -- No auto-scaling but base scaling is set: reapply base scaling to pixel text sizes - self.textSize = self.units.textSize.value * scaleY - - -- Protect against too-small text sizes (minimum 1px) - if self.textSize < 1 then - self.textSize = 1 -- Minimum 1px - end - end - - -- Final protection: ensure textSize is always at least 1px (catches all edge cases) - if self.text and self.textSize and self.textSize < 1 then - self.textSize = 1 -- Minimum 1px - end - - -- Recalculate gap if using viewport or percentage units - if self.units.gap.unit ~= "px" then - local containerSize = (self.flexDirection == FlexDirection.HORIZONTAL) and (self.parent and self.parent.width or newViewportWidth) - or (self.parent and self.parent.height or newViewportHeight) - self.gap = Units.resolve(self.units.gap.value, self.units.gap.unit, newViewportWidth, newViewportHeight, containerSize) - end - - -- Recalculate spacing (padding/margin) if using viewport or percentage units - -- For percentage-based padding: - -- - If element has a parent: use parent's border-box dimensions (CSS spec for child elements) - -- - If element has no parent: use element's own border-box dimensions (CSS spec for root elements) - local parentBorderBoxWidth = self.parent and self.parent._borderBoxWidth or self._borderBoxWidth or newViewportWidth - local parentBorderBoxHeight = self.parent and self.parent._borderBoxHeight or self._borderBoxHeight or newViewportHeight - - -- Handle shorthand properties first (horizontal/vertical) - local resolvedHorizontalPadding = nil - local resolvedVerticalPadding = nil - - if self.units.padding.horizontal and self.units.padding.horizontal.unit ~= "px" then - resolvedHorizontalPadding = - Units.resolve(self.units.padding.horizontal.value, self.units.padding.horizontal.unit, newViewportWidth, newViewportHeight, parentBorderBoxWidth) - elseif self.units.padding.horizontal and self.units.padding.horizontal.value then - resolvedHorizontalPadding = self.units.padding.horizontal.value - end - - if self.units.padding.vertical and self.units.padding.vertical.unit ~= "px" then - resolvedVerticalPadding = - Units.resolve(self.units.padding.vertical.value, self.units.padding.vertical.unit, newViewportWidth, newViewportHeight, parentBorderBoxHeight) - elseif self.units.padding.vertical and self.units.padding.vertical.value then - resolvedVerticalPadding = self.units.padding.vertical.value - end - - -- Resolve individual padding sides (with fallback to shorthand) - for _, side in ipairs({ "top", "right", "bottom", "left" }) do - -- Check if this side was explicitly set or if we should use shorthand - local useShorthand = false - if not self.units.padding[side].explicit then - -- Not explicitly set, check if we have shorthand - if side == "left" or side == "right" then - useShorthand = resolvedHorizontalPadding ~= nil - elseif side == "top" or side == "bottom" then - useShorthand = resolvedVerticalPadding ~= nil - end - end - - if useShorthand then - -- Use shorthand value - if side == "left" or side == "right" then - self.padding[side] = resolvedHorizontalPadding - else - self.padding[side] = resolvedVerticalPadding - end - elseif self.units.padding[side].unit ~= "px" then - -- Recalculate non-pixel units - local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth - self.padding[side] = Units.resolve(self.units.padding[side].value, self.units.padding[side].unit, newViewportWidth, newViewportHeight, parentSize) - end - -- If unit is "px" and not using shorthand, value stays the same - end - - -- Handle margin shorthand properties - local resolvedHorizontalMargin = nil - local resolvedVerticalMargin = nil - - if self.units.margin.horizontal and self.units.margin.horizontal.unit ~= "px" then - resolvedHorizontalMargin = - Units.resolve(self.units.margin.horizontal.value, self.units.margin.horizontal.unit, newViewportWidth, newViewportHeight, parentBorderBoxWidth) - elseif self.units.margin.horizontal and self.units.margin.horizontal.value then - resolvedHorizontalMargin = self.units.margin.horizontal.value - end - - if self.units.margin.vertical and self.units.margin.vertical.unit ~= "px" then - resolvedVerticalMargin = - Units.resolve(self.units.margin.vertical.value, self.units.margin.vertical.unit, newViewportWidth, newViewportHeight, parentBorderBoxHeight) - elseif self.units.margin.vertical and self.units.margin.vertical.value then - resolvedVerticalMargin = self.units.margin.vertical.value - end - - -- Resolve individual margin sides (with fallback to shorthand) - for _, side in ipairs({ "top", "right", "bottom", "left" }) do - -- Check if this side was explicitly set or if we should use shorthand - local useShorthand = false - if not self.units.margin[side].explicit then - -- Not explicitly set, check if we have shorthand - if side == "left" or side == "right" then - useShorthand = resolvedHorizontalMargin ~= nil - elseif side == "top" or side == "bottom" then - useShorthand = resolvedVerticalMargin ~= nil - end - end - - if useShorthand then - -- Use shorthand value - if side == "left" or side == "right" then - self.margin[side] = resolvedHorizontalMargin - else - self.margin[side] = resolvedVerticalMargin - end - elseif self.units.margin[side].unit ~= "px" then - -- Recalculate non-pixel units - local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth - self.margin[side] = Units.resolve(self.units.margin[side].value, self.units.margin[side].unit, newViewportWidth, newViewportHeight, parentSize) - end - -- If unit is "px" and not using shorthand, value stays the same - end - - -- BORDER-BOX MODEL: Calculate content dimensions from border-box dimensions - -- For explicitly-sized elements (non-auto), _borderBoxWidth/_borderBoxHeight were set earlier - -- Now we calculate content width/height by subtracting padding - -- Only recalculate if using viewport/percentage units (where _borderBoxWidth actually changed) - if self.units.width.unit ~= "auto" and self.units.width.unit ~= "px" then - -- _borderBoxWidth was recalculated for viewport/percentage units - -- Calculate content width by subtracting padding - self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) - elseif self.units.width.unit == "auto" then - -- For auto-sized elements, width is content width (calculated in resize method) - -- Update border-box to include padding - self._borderBoxWidth = self.width + self.padding.left + self.padding.right - end - -- For pixel units, width stays as-is (may have been manually modified) - - if self.units.height.unit ~= "auto" and self.units.height.unit ~= "px" then - -- _borderBoxHeight was recalculated for viewport/percentage units - -- Calculate content height by subtracting padding - self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) - elseif self.units.height.unit == "auto" then - -- For auto-sized elements, height is content height (calculated in resize method) - -- Update border-box to include padding - self._borderBoxHeight = self.height + self.padding.top + self.padding.bottom - end - -- For pixel units, height stays as-is (may have been manually modified) - - -- Detect overflow after layout calculations - self:_detectOverflow() -end - ---- Resize element and its children based on game window size change ----@param newGameWidth number ----@param newGameHeight number -function Element:resize(newGameWidth, newGameHeight) - self:recalculateUnits(newGameWidth, newGameHeight) - - -- For non-auto-sized elements with viewport/percentage units, update content dimensions from border-box - if not self.autosizing.width and self._borderBoxWidth and self.units.width.unit ~= "px" then - self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) - end - if not self.autosizing.height and self._borderBoxHeight and self.units.height.unit ~= "px" then - self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) - end - - -- Update children - for _, child in ipairs(self.children) do - child:resize(newGameWidth, newGameHeight) - end - - -- Recalculate auto-sized dimensions after children are resized - if self.autosizing.width then - local contentWidth = self:calculateAutoWidth() - -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content - self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right - self.width = contentWidth - end - if self.autosizing.height then - local contentHeight = self:calculateAutoHeight() - -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content - self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom - self.height = contentHeight - end - - -- Re-resolve ew/eh textSize units after all dimensions are finalized - -- This ensures textSize updates based on current width/height (whether calculated or manually set) - if self.units.textSize.value then - local unit = self.units.textSize.unit - local value = self.units.textSize.value - local _, scaleY = Gui.getScaleFactors() - - if unit == "ew" then - -- Element width relative (use current width) - self.textSize = (value / 100) * self.width - - -- Apply min/max constraints - local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize) - local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) - if minSize and self.textSize < minSize then - self.textSize = minSize - end - if maxSize and self.textSize > maxSize then - self.textSize = maxSize - end - if self.textSize < 1 then - self.textSize = 1 - end - elseif unit == "eh" then - -- Element height relative (use current height) - self.textSize = (value / 100) * self.height - - -- Apply min/max constraints - local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize) - local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) - if minSize and self.textSize < minSize then - self.textSize = minSize - end - if maxSize and self.textSize > maxSize then - self.textSize = maxSize - end - if self.textSize < 1 then - self.textSize = 1 - end - end - end - - self:layoutChildren() - self.prevGameSize.width = newGameWidth - self.prevGameSize.height = newGameHeight -end - ---- Calculate text width for button ----@return number -function Element:calculateTextWidth() - if self.text == nil then - return 0 - end - - if self.textSize then - -- Resolve font path from font family (same logic as in draw) - local fontPath = nil - if self.fontFamily then - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then - fontPath = themeToUse.fonts[self.fontFamily] - else - fontPath = self.fontFamily - end - elseif self.themeComponent then - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts.default then - fontPath = themeToUse.fonts.default - end - end - - local tempFont = FONT_CACHE.get(self.textSize, fontPath) - local width = tempFont:getWidth(self.text) - -- Apply contentAutoSizingMultiplier if set - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then - width = width * self.contentAutoSizingMultiplier.width - end - return width - end - - local font = love.graphics.getFont() - local width = font:getWidth(self.text) - -- Apply contentAutoSizingMultiplier if set - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then - width = width * self.contentAutoSizingMultiplier.width - end - return width -end - ----@return number -function Element:calculateTextHeight() - if self.text == nil then - return 0 - end - - -- Get the font - local font - if self.textSize then - -- Resolve font path from font family (same logic as in draw) - local fontPath = nil - if self.fontFamily then - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then - fontPath = themeToUse.fonts[self.fontFamily] - else - fontPath = self.fontFamily - end - elseif self.themeComponent then - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts.default then - fontPath = themeToUse.fonts.default - end - end - font = FONT_CACHE.get(self.textSize, fontPath) - else - font = love.graphics.getFont() - end - - local height = font:getHeight() - - -- If text wrapping is enabled, calculate height based on wrapped lines - if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then - -- Calculate available width for wrapping - local availableWidth = self.width - if availableWidth and availableWidth > 0 then - -- Get the wrapped text lines using getWrap (returns width and table of lines) - local wrappedWidth, wrappedLines = font:getWrap(self.text, availableWidth) - -- Height is line height * number of lines - height = height * #wrappedLines - end - end - - -- Apply contentAutoSizingMultiplier if set - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.height then - height = height * self.contentAutoSizingMultiplier.height - end - - return height -end - -function Element:calculateAutoWidth() - -- BORDER-BOX MODEL: Calculate content width, caller will add padding to get border-box - local contentWidth = self:calculateTextWidth() - if not self.children or #self.children == 0 then - return contentWidth - end - - -- For HORIZONTAL flex: sum children widths + gaps - -- For VERTICAL flex: max of children widths - local isHorizontal = self.flexDirection == "horizontal" - local totalWidth = contentWidth - local maxWidth = contentWidth - local participatingChildren = 0 - - for _, child in ipairs(self.children) do - -- Skip explicitly absolute positioned children as they don't affect parent auto-sizing - if not child._explicitlyAbsolute then - -- BORDER-BOX MODEL: Use border-box width for auto-sizing calculations - local childBorderBoxWidth = child:getBorderBoxWidth() - if isHorizontal then - totalWidth = totalWidth + childBorderBoxWidth - else - maxWidth = math.max(maxWidth, childBorderBoxWidth) - end - participatingChildren = participatingChildren + 1 - end - end - - if isHorizontal then - -- Add gaps between children (n-1 gaps for n children) - local gapCount = math.max(0, participatingChildren - 1) - return totalWidth + (self.gap * gapCount) - else - return maxWidth - end -end - ---- Calculate auto height based on children -function Element:calculateAutoHeight() - local height = self:calculateTextHeight() - if not self.children or #self.children == 0 then - return height - end - - -- For VERTICAL flex: sum children heights + gaps - -- For HORIZONTAL flex: max of children heights - local isVertical = self.flexDirection == "vertical" - local totalHeight = height - local maxHeight = height - local participatingChildren = 0 - - for _, child in ipairs(self.children) do - -- Skip explicitly absolute positioned children as they don't affect parent auto-sizing - if not child._explicitlyAbsolute then - -- BORDER-BOX MODEL: Use border-box height for auto-sizing calculations - local childBorderBoxHeight = child:getBorderBoxHeight() - if isVertical then - totalHeight = totalHeight + childBorderBoxHeight - else - maxHeight = math.max(maxHeight, childBorderBoxHeight) - end - participatingChildren = participatingChildren + 1 - end - end - - if isVertical then - -- Add gaps between children (n-1 gaps for n children) - local gapCount = math.max(0, participatingChildren - 1) - return totalHeight + (self.gap * gapCount) - else - return maxHeight - end -end - ----@param newText string ----@param autoresize boolean? --default: false -function Element:updateText(newText, autoresize) - self.text = newText or self.text - if autoresize then - self.width = self:calculateTextWidth() - self.height = self:calculateTextHeight() - end -end - ----@param newOpacity number -function Element:updateOpacity(newOpacity) - self.opacity = newOpacity - for _, child in ipairs(self.children) do - child:updateOpacity(newOpacity) - end -end - ---- same as calling updateOpacity(0) -function Element:hide() - self:updateOpacity(0) -end - ---- same as calling updateOpacity(1) -function Element:show() - self:updateOpacity(1) -end - --- ==================== --- Input Handling - Cursor Management --- ==================== - ---- Set cursor position ----@param position number -- Character index (0-based) -function Element:setCursorPosition(position) - if not self.editable then - return - end - self._cursorPosition = position - self:_validateCursorPosition() - self:_resetCursorBlink() -end - ---- Get cursor position ----@return number -- Character index (0-based) -function Element:getCursorPosition() - if not self.editable then - return 0 - end - return self._cursorPosition -end - ---- Move cursor by delta characters ----@param delta number -- Number of characters to move (positive or negative) -function Element:moveCursorBy(delta) - if not self.editable then - return - end - self._cursorPosition = self._cursorPosition + delta - self:_validateCursorPosition() - self:_resetCursorBlink() -end - ---- Move cursor to start of text -function Element:moveCursorToStart() - if not self.editable then - return - end - self._cursorPosition = 0 - self:_resetCursorBlink() -end - ---- Move cursor to end of text -function Element:moveCursorToEnd() - if not self.editable then - return - end - local textLength = utf8.len(self._textBuffer or "") - self._cursorPosition = textLength - self:_resetCursorBlink() -end - ---- Move cursor to start of current line -function Element:moveCursorToLineStart() - if not self.editable then - return - end - -- For now, just move to start (will be enhanced for multi-line) - self:moveCursorToStart() -end - ---- Move cursor to end of current line -function Element:moveCursorToLineEnd() - if not self.editable then - return - end - -- For now, just move to end (will be enhanced for multi-line) - self:moveCursorToEnd() -end - ---- Validate cursor position (ensure it's within text bounds) -function Element:_validateCursorPosition() - if not self.editable then - return - end - local textLength = utf8.len(self._textBuffer or "") - self._cursorPosition = math.max(0, math.min(self._cursorPosition, textLength)) -end - ---- Reset cursor blink (show cursor immediately) -function Element:_resetCursorBlink() - if not self.editable then - return - end - self._cursorBlinkTimer = 0 - self._cursorVisible = true -end - --- ==================== --- Input Handling - Selection Management --- ==================== - ---- Set selection range ----@param startPos number -- Start position (inclusive) ----@param endPos number -- End position (inclusive) -function Element:setSelection(startPos, endPos) - if not self.editable then - return - end - local textLength = utf8.len(self._textBuffer or "") - self._selectionStart = math.max(0, math.min(startPos, textLength)) - self._selectionEnd = math.max(0, math.min(endPos, textLength)) - - -- Ensure start <= end - if self._selectionStart > self._selectionEnd then - self._selectionStart, self._selectionEnd = self._selectionEnd, self._selectionStart - end - - self:_resetCursorBlink() -end - ---- Get selection range ----@return number?, number? -- Start and end positions, or nil if no selection -function Element:getSelection() - if not self.editable then - return nil, nil - end - if not self:hasSelection() then - return nil, nil - end - return self._selectionStart, self._selectionEnd -end - ---- Check if there is an active selection ----@return boolean -function Element:hasSelection() - if not self.editable then - return false - end - return self._selectionStart ~= nil and self._selectionEnd ~= nil and self._selectionStart ~= self._selectionEnd -end - ---- Clear selection -function Element:clearSelection() - if not self.editable then - return - end - self._selectionStart = nil - self._selectionEnd = nil - self._selectionAnchor = nil -end - ---- Select all text -function Element:selectAll() - if not self.editable then - return - end - local textLength = utf8.len(self._textBuffer or "") - self._selectionStart = 0 - self._selectionEnd = textLength - self:_resetCursorBlink() -end - ---- Get selected text ----@return string? -- Selected text or nil if no selection -function Element:getSelectedText() - if not self.editable or not self:hasSelection() then - return nil - end - local startPos, endPos = self:getSelection() - if not startPos or not endPos then - return nil - end - - -- Convert character indices to byte offsets for utf8.sub - local text = self._textBuffer or "" - return utf8.sub(text, startPos + 1, endPos) -end - ---- Delete selected text ----@return boolean -- True if text was deleted -function Element:deleteSelection() - if not self.editable or not self:hasSelection() then - return false - end - local startPos, endPos = self:getSelection() - if not startPos or not endPos then - return false - end - - self:deleteText(startPos, endPos) - self:clearSelection() - self._cursorPosition = startPos - self:_validateCursorPosition() - return true -end - --- ==================== --- Input Handling - Focus Management --- ==================== - ---- Focus this element for keyboard input -function Element:focus() - if not self.editable then - return - end - - -- Blur previously focused element - if Gui._focusedElement and Gui._focusedElement ~= self then - Gui._focusedElement:blur() - end - - -- Set focus state - self._focused = true - Gui._focusedElement = self - - -- Reset cursor blink - self:_resetCursorBlink() - - -- Select all text if selectOnFocus is enabled - if self.selectOnFocus then - self:selectAll() - else - -- Move cursor to end of text - self:moveCursorToEnd() - end - - -- Trigger onFocus callback if defined - if self.onFocus then - self.onFocus(self) - end -end - ---- Remove focus from this element -function Element:blur() - if not self.editable then - return - end - - self._focused = false - - -- Clear global focused element if it's this element - if Gui._focusedElement == self then - Gui._focusedElement = nil - end - - -- Trigger onBlur callback if defined - if self.onBlur then - self.onBlur(self) - end -end - ---- Check if this element is focused ----@return boolean -function Element:isFocused() - if not self.editable then - return false - end - return self._focused == true -end - --- ==================== --- Input Handling - Text Buffer Management --- ==================== - ---- Get current text buffer ----@return string -function Element:getText() - if not self.editable then - return self.text or "" - end - return self._textBuffer or "" -end - ---- Set text buffer and mark dirty ----@param text string -function Element:setText(text) - if not self.editable then - self.text = text - return - end - - self._textBuffer = text or "" - self.text = self._textBuffer -- Sync display text - self:_markTextDirty() - self:_validateCursorPosition() -end - ---- Insert text at position ----@param text string -- Text to insert ----@param position number? -- Position to insert at (default: cursor position) -function Element:insertText(text, position) - if not self.editable then - return - end - - position = position or self._cursorPosition - local buffer = self._textBuffer or "" - - -- Convert character position to byte offset - local byteOffset = utf8.offset(buffer, position + 1) or (#buffer + 1) - - -- Insert text - local before = buffer:sub(1, byteOffset - 1) - local after = buffer:sub(byteOffset) - self._textBuffer = before .. text .. after - self.text = self._textBuffer -- Sync display text - - -- Update cursor position - self._cursorPosition = position + utf8.len(text) - - self:_markTextDirty() - self:_validateCursorPosition() -end - ---- Delete text in range ----@param startPos number -- Start position (inclusive) ----@param endPos number -- End position (inclusive) -function Element:deleteText(startPos, endPos) - if not self.editable then - return - end - - local buffer = self._textBuffer or "" - - -- Ensure valid range - local textLength = utf8.len(buffer) - startPos = math.max(0, math.min(startPos, textLength)) - endPos = math.max(0, math.min(endPos, textLength)) - - if startPos > endPos then - startPos, endPos = endPos, startPos - end - - -- Convert character positions to byte offsets - local startByte = utf8.offset(buffer, startPos + 1) or 1 - local endByte = utf8.offset(buffer, endPos + 1) or (#buffer + 1) - - -- Delete text - local before = buffer:sub(1, startByte - 1) - local after = buffer:sub(endByte) - self._textBuffer = before .. after - self.text = self._textBuffer -- Sync display text - - self:_markTextDirty() -end - ---- Replace text in range ----@param startPos number -- Start position (inclusive) ----@param endPos number -- End position (inclusive) ----@param newText string -- Replacement text -function Element:replaceText(startPos, endPos, newText) - if not self.editable then - return - end - - self:deleteText(startPos, endPos) - self:insertText(newText, startPos) -end - ---- Mark text as dirty (needs recalculation) -function Element:_markTextDirty() - if not self.editable then - return - end - self._textDirty = true -end - ---- Update text if dirty (recalculate lines and wrapping) -function Element:_updateTextIfDirty() - if not self.editable or not self._textDirty then - return - end - - self:_splitLines() - self:_calculateWrapping() - self:_validateCursorPosition() - self._textDirty = false -end - ---- Split text into lines (for multi-line text) -function Element:_splitLines() - if not self.editable then - return - end - - if not self.multiline then - self._lines = { self._textBuffer or "" } - return - end - - self._lines = {} - local text = self._textBuffer or "" - - -- Split on newlines - for line in (text .. "\n"):gmatch("([^\n]*)\n") do - table.insert(self._lines, line) - end - - -- Ensure at least one line - if #self._lines == 0 then - self._lines = { "" } - end -end - ---- Calculate text wrapping -function Element:_calculateWrapping() - if not self.editable or not self.textWrap then - self._wrappedLines = nil - return - end - - self._wrappedLines = {} - local availableWidth = self.width - self.padding.left - self.padding.right - - for lineNum, line in ipairs(self._lines or {}) do - if line == "" then - table.insert(self._wrappedLines, { - text = "", - startIdx = 0, - endIdx = 0, - lineNum = lineNum, - }) - else - local wrappedParts = self:_wrapLine(line, availableWidth) - for _, part in ipairs(wrappedParts) do - part.lineNum = lineNum - table.insert(self._wrappedLines, part) - end - end - end -end - ---- Wrap a single line of text ----@param line string -- Line to wrap ----@param maxWidth number -- Maximum width in pixels ----@return table -- Array of wrapped line parts -function Element:_wrapLine(line, maxWidth) - if not self.editable then - return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } - end - - local font = self:_getFont() - local wrappedParts = {} - local currentLine = "" - local startIdx = 0 - - if self.textWrap == "word" then - -- Word wrapping - local words = {} - for word in line:gmatch("%S+") do - table.insert(words, word) - end - - for i, word in ipairs(words) do - local testLine = currentLine == "" and word or (currentLine .. " " .. word) - local width = font:getWidth(testLine) - - if width > maxWidth and currentLine ~= "" then - -- Current line is full, start new line - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + utf8.len(currentLine), - }) - currentLine = word - startIdx = startIdx + utf8.len(currentLine) + 1 - else - currentLine = testLine - end - end - else - -- Character wrapping - local lineLength = utf8.len(line) - for i = 1, lineLength do - local char = utf8.sub(line, i, i) - local testLine = currentLine .. char - local width = font:getWidth(testLine) - - if width > maxWidth and currentLine ~= "" then - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + utf8.len(currentLine), - }) - currentLine = char - startIdx = i - 1 - else - currentLine = testLine - end - end - end - - -- Add remaining text - if currentLine ~= "" then - table.insert(wrappedParts, { - text = currentLine, - startIdx = startIdx, - endIdx = startIdx + utf8.len(currentLine), - }) - end - - -- Ensure at least one part - if #wrappedParts == 0 then - table.insert(wrappedParts, { - text = "", - startIdx = 0, - endIdx = 0, - }) - end - - return wrappedParts -end - ---- Get font for text rendering ----@return love.Font -function Element:_getFont() - -- Get font path from theme or element - local fontPath = nil - if self.fontFamily then - local themeToUse = self.theme and themes[self.theme] or Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then - fontPath = themeToUse.fonts[self.fontFamily] - else - -- Assume fontFamily is a direct path - fontPath = self.fontFamily - end - end - - return FONT_CACHE.getFont(self.textSize, fontPath) -end - --- ==================== --- Input Handling - Keyboard Input --- ==================== - ---- Handle text input (character input) ----@param text string -- Character(s) to insert -function Element:textinput(text) - if not self.editable or not self._focused then - return - end - - -- Trigger onTextInput callback if defined - if self.onTextInput then - local result = self.onTextInput(self, text) - -- If callback returns false, cancel the input - if result == false then - return - end - end - - -- Capture old text for callback - local oldText = self._textBuffer - - -- Delete selection if exists - local hadSelection = self:hasSelection() - if hadSelection then - self:deleteSelection() - end - - -- Insert text at cursor position - self:insertText(text) - - -- Trigger onTextChange callback if text changed - if self.onTextChange and self._textBuffer ~= oldText then - self.onTextChange(self, self._textBuffer, oldText) - end -end - ---- Handle key press (special keys) ----@param key string -- Key name ----@param scancode string -- Scancode ----@param isrepeat boolean -- Whether this is a key repeat -function Element:keypressed(key, scancode, isrepeat) - if not self.editable or not self._focused then - return - end - - local modifiers = getModifiers() - local ctrl = modifiers.ctrl or modifiers.super -- Support both Ctrl and Cmd - - -- Handle cursor movement - if key == "left" then - if self:hasSelection() and not modifiers.shift then - -- Move to start of selection - local startPos, _ = self:getSelection() - self._cursorPosition = startPos - self:clearSelection() - else - self:moveCursorBy(-1) - end - self:_resetCursorBlink() - elseif key == "right" then - if self:hasSelection() and not modifiers.shift then - -- Move to end of selection - local _, endPos = self:getSelection() - self._cursorPosition = endPos - self:clearSelection() - else - self:moveCursorBy(1) - end - self:_resetCursorBlink() - elseif key == "home" or (ctrl and key == "a" and not self.multiline) then - -- Move to line start (or document start for single-line) - if ctrl or not self.multiline then - self:moveCursorToStart() - else - self:moveCursorToLineStart() - end - if key == "home" then - self:clearSelection() - end - self:_resetCursorBlink() - elseif key == "end" or (ctrl and key == "e" and not self.multiline) then - -- Move to line end (or document end for single-line) - if ctrl or not self.multiline then - self:moveCursorToEnd() - else - self:moveCursorToLineEnd() - end - if key == "end" then - self:clearSelection() - end - self:_resetCursorBlink() - - -- Handle backspace and delete - elseif key == "backspace" then - local oldText = self._textBuffer - if self:hasSelection() then - -- Delete selection - self:deleteSelection() - elseif self._cursorPosition > 0 then - -- Delete character before cursor - self:deleteText(self._cursorPosition - 1, self._cursorPosition) - self._cursorPosition = self._cursorPosition - 1 - self:_validateCursorPosition() - end - - -- Trigger onTextChange callback - if self.onTextChange and self._textBuffer ~= oldText then - self.onTextChange(self, self._textBuffer, oldText) - end - self:_resetCursorBlink() - elseif key == "delete" then - local oldText = self._textBuffer - if self:hasSelection() then - -- Delete selection - self:deleteSelection() - else - -- Delete character after cursor - local textLength = utf8.len(self._textBuffer or "") - if self._cursorPosition < textLength then - self:deleteText(self._cursorPosition, self._cursorPosition + 1) - end - end - - -- Trigger onTextChange callback - if self.onTextChange and self._textBuffer ~= oldText then - self.onTextChange(self, self._textBuffer, oldText) - end - self:_resetCursorBlink() - - -- Handle return/enter - elseif key == "return" or key == "kpenter" then - if self.multiline then - -- Insert newline - local oldText = self._textBuffer - if self:hasSelection() then - self:deleteSelection() - end - self:insertText("\n") - - -- Trigger onTextChange callback - if self.onTextChange and self._textBuffer ~= oldText then - self.onTextChange(self, self._textBuffer, oldText) - end - else - -- Trigger onEnter callback for single-line - if self.onEnter then - self.onEnter(self) - end - end - self:_resetCursorBlink() - - -- Handle Ctrl/Cmd+A (select all) - elseif ctrl and key == "a" then - self:selectAll() - self:_resetCursorBlink() - - -- Handle Escape - elseif key == "escape" then - if self:hasSelection() then - -- Clear selection - self:clearSelection() - else - -- Blur element - self:blur() - end - self:_resetCursorBlink() - end -end - Gui.new = Element.new Gui.Element = Element Gui.Animation = Animation @@ -7170,16 +600,34 @@ Gui.ImageDataReader = ImageDataReader Gui.NinePatchParser = NinePatchParser return { - GUI = Gui, + -- Core Gui = Gui, + GUI = Gui, -- Backward compatibility alias Element = Element, + + -- Submodules (exposed for direct access) + Blur = Blur, Color = Color, - Theme = Theme, - Animation = Animation, + ImageDataReader = ImageDataReader, + NinePatchParser = NinePatchParser, ImageScaler = ImageScaler, ImageCache = ImageCache, ImageRenderer = ImageRenderer, - ImageDataReader = ImageDataReader, - NinePatchParser = NinePatchParser, + Theme = Theme, + RoundedRect = RoundedRect, + NineSlice = NineSlice, + + -- Enums (individual) + Positioning = Positioning, + FlexDirection = FlexDirection, + JustifyContent = JustifyContent, + AlignContent = AlignContent, + AlignItems = AlignItems, + TextAlign = TextAlign, + AlignSelf = AlignSelf, + JustifySelf = JustifySelf, + FlexWrap = FlexWrap, + + -- Enums (backward compatibility - grouped) enums = enums, } diff --git a/flexlove/Animation.lua b/flexlove/Animation.lua new file mode 100644 index 0000000..cd8c958 --- /dev/null +++ b/flexlove/Animation.lua @@ -0,0 +1,188 @@ +---@class Animation +---@field duration number +---@field start {width?:number, height?:number, opacity?:number} +---@field final {width?:number, height?:number, opacity?:number} +---@field elapsed number +---@field transform table? +---@field transition table? +--- Easing functions for animations +local Easing = { + linear = function(t) + return t + end, + + easeInQuad = function(t) + return t * t + end, + easeOutQuad = function(t) + return t * (2 - t) + end, + easeInOutQuad = function(t) + return t < 0.5 and 2 * t * t or -1 + (4 - 2 * t) * t + end, + + easeInCubic = function(t) + return t * t * t + end, + easeOutCubic = function(t) + local t1 = t - 1 + return t1 * t1 * t1 + 1 + end, + easeInOutCubic = function(t) + return t < 0.5 and 4 * t * t * t or (t - 1) * (2 * t - 2) * (2 * t - 2) + 1 + end, + + easeInQuart = function(t) + return t * t * t * t + end, + easeOutQuart = function(t) + local t1 = t - 1 + return 1 - t1 * t1 * t1 * t1 + end, + + easeInExpo = function(t) + return t == 0 and 0 or math.pow(2, 10 * (t - 1)) + end, + easeOutExpo = function(t) + return t == 1 and 1 or 1 - math.pow(2, -10 * t) + end, +} + +local Animation = {} +Animation.__index = Animation + +---@class AnimationProps +---@field duration number +---@field start {width?:number, height?:number, opacity?:number} +---@field final {width?:number, height?:number, opacity?:number} +---@field transform table? +---@field transition table? +local AnimationProps = {} + +---@class TransformProps +---@field scale {x?:number, y?:number}? +---@field rotate number? +---@field translate {x?:number, y?:number}? +---@field skew {x?:number, y?:number}? + +---@class TransitionProps +---@field duration number? +---@field easing string? + +---@param props AnimationProps +---@return Animation +function Animation.new(props) + local self = setmetatable({}, Animation) + self.duration = props.duration + self.start = props.start + self.final = props.final + self.transform = props.transform + self.transition = props.transition + self.elapsed = 0 + + -- Set easing function (default to linear) + local easingName = props.easing or "linear" + self.easing = Easing[easingName] or Easing.linear + + -- Pre-allocate result table to avoid GC pressure + self._cachedResult = {} + self._resultDirty = true + + return self +end + +---@param dt number +---@return boolean +function Animation:update(dt) + self.elapsed = self.elapsed + dt + self._resultDirty = true -- Mark cached result as dirty + if self.elapsed >= self.duration then + return true -- finished + else + return false + end +end + +---@return table +function Animation:interpolate() + -- Return cached result if not dirty (avoids recalculation) + if not self._resultDirty then + return self._cachedResult + end + + local t = math.min(self.elapsed / self.duration, 1) + t = self.easing(t) -- Apply easing function + local result = self._cachedResult -- Reuse existing table + + -- Clear previous values + result.width = nil + result.height = nil + result.opacity = nil + + -- Handle width and height if present + if self.start.width and self.final.width then + result.width = self.start.width * (1 - t) + self.final.width * t + end + + if self.start.height and self.final.height then + result.height = self.start.height * (1 - t) + self.final.height * t + end + + -- Handle other properties like opacity + if self.start.opacity and self.final.opacity then + result.opacity = self.start.opacity * (1 - t) + self.final.opacity * t + end + + -- Apply transform if present + if self.transform then + for key, value in pairs(self.transform) do + result[key] = value + end + end + + self._resultDirty = false -- Mark as clean + return result +end + +--- Apply animation to a GUI element +---@param element Element +function Animation:apply(element) + if element.animation then + -- If there's an existing animation, we should probably stop it or replace it + element.animation = self + else + element.animation = self + end +end + +--- Create a simple fade animation +---@param duration number +---@param fromOpacity number +---@param toOpacity number +---@return Animation +function Animation.fade(duration, fromOpacity, toOpacity) + return Animation.new({ + duration = duration, + start = { opacity = fromOpacity }, + final = { opacity = toOpacity }, + transform = {}, + transition = {}, + }) +end + +--- Create a simple scale animation +---@param duration number +---@param fromScale table{width:number,height:number} +---@param toScale table{width:number,height:number} +---@return Animation +function Animation.scale(duration, fromScale, toScale) + return Animation.new({ + duration = duration, + start = { width = fromScale.width, height = fromScale.height }, + final = { width = toScale.width, height = toScale.height }, + transform = {}, + transition = {}, + }) +end + +return Animation diff --git a/flexlove/Blur.lua b/flexlove/Blur.lua new file mode 100644 index 0000000..ef17ab2 --- /dev/null +++ b/flexlove/Blur.lua @@ -0,0 +1,302 @@ +--[[ +FlexLove Blur Module +Fast Gaussian blur implementation with canvas caching +]] + +local Blur = {} + +-- Canvas cache to avoid recreating canvases every frame +local canvasCache = {} +local MAX_CACHE_SIZE = 20 + +--- Build Gaussian blur shader with given parameters +---@param taps number -- Number of samples (must be odd, >= 3) +---@param offset number -- Offset multiplier for sampling +---@param offset_type string -- "weighted" or "center" +---@param sigma number -- Gaussian sigma value +---@return love.Shader +local function buildShader(taps, offset, offset_type, sigma) + taps = math.floor(taps) + sigma = sigma >= 1 and sigma or (taps - 1) * offset / 6 + sigma = math.max(sigma, 1) + + local steps = (taps + 1) / 2 + + -- Calculate gaussian function + local g_offsets = {} + local g_weights = {} + for i = 1, steps, 1 do + g_offsets[i] = offset * (i - 1) + g_weights[i] = math.exp(-0.5 * (g_offsets[i] - 0) ^ 2 * 1 / sigma ^ 2) + end + + -- Calculate offsets and weights for sub-pixel samples + local offsets = {} + local weights = {} + for i = #g_weights, 2, -2 do + local oA, oB = g_offsets[i], g_offsets[i - 1] + local wA, wB = g_weights[i], g_weights[i - 1] + wB = oB == 0 and wB / 2 or wB + local weight = wA + wB + offsets[#offsets + 1] = offset_type == "center" and (oA + oB) / 2 or (oA * wA + oB * wB) / weight + weights[#weights + 1] = weight + end + + local code = { [[ + extern vec2 direction; + vec4 effect(vec4 color, Image tex, vec2 tc, vec2 sc) {]] } + + local norm = 0 + if #g_weights % 2 == 0 then + code[#code + 1] = "vec4 c = vec4( 0.0 );" + else + local weight = g_weights[1] + norm = norm + weight + code[#code + 1] = ("vec4 c = %f * texture2D(tex, tc);"):format(weight) + end + + local tmpl = "c += %f * ( texture2D(tex, tc + %f * direction)+ texture2D(tex, tc - %f * direction));\n" + for i = 1, #offsets, 1 do + local offset = offsets[i] + local weight = weights[i] + norm = norm + weight * 2 + code[#code + 1] = tmpl:format(weight, offset, offset) + end + code[#code + 1] = ("return c * vec4(%f) * color; }"):format(1 / norm) + + local shader = table.concat(code) + return love.graphics.newShader(shader) +end + +--- Get or create a canvas from cache +---@param width number +---@param height number +---@return love.Canvas +local function getCanvas(width, height) + local key = string.format("%dx%d", width, height) + + if not canvasCache[key] then + canvasCache[key] = {} + end + + local cache = canvasCache[key] + + -- Try to reuse existing canvas + for i, canvas in ipairs(cache) do + if not canvas.inUse then + canvas.inUse = true + return canvas.canvas + end + end + + -- Create new canvas if none available + local canvas = love.graphics.newCanvas(width, height) + table.insert(cache, { canvas = canvas, inUse = true }) + + -- Limit cache size + if #cache > MAX_CACHE_SIZE then + table.remove(cache, 1) + end + + return canvas +end + +--- Release a canvas back to the cache +---@param canvas love.Canvas +local function releaseCanvas(canvas) + for _, sizeCache in pairs(canvasCache) do + for _, entry in ipairs(sizeCache) do + if entry.canvas == canvas then + entry.inUse = false + return + end + end + end +end + +--- Create a blur effect instance +---@param quality number -- Quality level (1-10, higher = better quality but slower) +---@return table -- Blur effect instance +function Blur.new(quality) + quality = math.max(1, math.min(10, quality or 5)) + + -- Map quality to shader parameters + -- Quality 1: 3 taps (fastest, lowest quality) + -- Quality 5: 7 taps (balanced) + -- Quality 10: 15 taps (slowest, highest quality) + local taps = 3 + (quality - 1) * 1.5 + taps = math.floor(taps) + if taps % 2 == 0 then + taps = taps + 1 -- Ensure odd number + end + + local offset = 1.0 + local offset_type = "weighted" + local sigma = -1 + + local shader = buildShader(taps, offset, offset_type, sigma) + + local instance = { + shader = shader, + quality = quality, + taps = taps, + } + + return instance +end + +--- Apply blur to a region of the screen +---@param blurInstance table -- Blur effect instance from Blur.new() +---@param intensity number -- Blur intensity (0-100) +---@param x number -- X position +---@param y number -- Y position +---@param width number -- Width +---@param height number -- Height +---@param drawFunc function -- Function to draw content to be blurred +function Blur.applyToRegion(blurInstance, intensity, x, y, width, height, drawFunc) + if intensity <= 0 or width <= 0 or height <= 0 then + -- No blur, just draw normally + drawFunc() + return + end + + -- Clamp intensity + intensity = math.max(0, math.min(100, intensity)) + + -- Calculate blur passes based on intensity + -- Intensity 0-100 maps to 0-5 passes + local passes = math.ceil(intensity / 20) + passes = math.max(1, math.min(5, passes)) + + -- Get canvases for ping-pong rendering + local canvas1 = getCanvas(width, height) + local canvas2 = getCanvas(width, height) + + -- Save graphics state + local prevCanvas = love.graphics.getCanvas() + local prevShader = love.graphics.getShader() + local prevColor = { love.graphics.getColor() } + local prevBlendMode = love.graphics.getBlendMode() + + -- Render content to first canvas + love.graphics.setCanvas(canvas1) + love.graphics.clear() + love.graphics.push() + love.graphics.origin() + love.graphics.translate(-x, -y) + drawFunc() + love.graphics.pop() + + -- Apply blur passes + love.graphics.setShader(blurInstance.shader) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("alpha", "premultiplied") + + for i = 1, passes do + -- Horizontal pass + love.graphics.setCanvas(canvas2) + love.graphics.clear() + blurInstance.shader:send("direction", { 1 / width, 0 }) + love.graphics.draw(canvas1, 0, 0) + + -- Vertical pass + love.graphics.setCanvas(canvas1) + love.graphics.clear() + blurInstance.shader:send("direction", { 0, 1 / height }) + love.graphics.draw(canvas2, 0, 0) + end + + -- Draw blurred result to screen + love.graphics.setCanvas(prevCanvas) + love.graphics.setShader() + love.graphics.setBlendMode(prevBlendMode) + love.graphics.draw(canvas1, x, y) + + -- Restore graphics state + love.graphics.setShader(prevShader) + love.graphics.setColor(unpack(prevColor)) + + -- Release canvases back to cache + releaseCanvas(canvas1) + releaseCanvas(canvas2) +end + +--- Apply backdrop blur effect (blur content behind a region) +---@param blurInstance table -- Blur effect instance from Blur.new() +---@param intensity number -- Blur intensity (0-100) +---@param x number -- X position +---@param y number -- Y position +---@param width number -- Width +---@param height number -- Height +---@param backdropCanvas love.Canvas -- Canvas containing the backdrop content +function Blur.applyBackdrop(blurInstance, intensity, x, y, width, height, backdropCanvas) + if intensity <= 0 or width <= 0 or height <= 0 then + return + end + + -- Clamp intensity + intensity = math.max(0, math.min(100, intensity)) + + -- Calculate blur passes based on intensity + local passes = math.ceil(intensity / 20) + passes = math.max(1, math.min(5, passes)) + + -- Get canvases for ping-pong rendering + local canvas1 = getCanvas(width, height) + local canvas2 = getCanvas(width, height) + + -- Save graphics state + local prevCanvas = love.graphics.getCanvas() + local prevShader = love.graphics.getShader() + local prevColor = { love.graphics.getColor() } + local prevBlendMode = love.graphics.getBlendMode() + + -- Extract the region from backdrop + love.graphics.setCanvas(canvas1) + love.graphics.clear() + love.graphics.setColor(1, 1, 1, 1) + love.graphics.setBlendMode("alpha", "premultiplied") + + -- Create a quad for the region + local backdropWidth, backdropHeight = backdropCanvas:getDimensions() + local quad = love.graphics.newQuad(x, y, width, height, backdropWidth, backdropHeight) + love.graphics.draw(backdropCanvas, quad, 0, 0) + + -- Apply blur passes + love.graphics.setShader(blurInstance.shader) + + for i = 1, passes do + -- Horizontal pass + love.graphics.setCanvas(canvas2) + love.graphics.clear() + blurInstance.shader:send("direction", { 1 / width, 0 }) + love.graphics.draw(canvas1, 0, 0) + + -- Vertical pass + love.graphics.setCanvas(canvas1) + love.graphics.clear() + blurInstance.shader:send("direction", { 0, 1 / height }) + love.graphics.draw(canvas2, 0, 0) + end + + -- Draw blurred result to screen + love.graphics.setCanvas(prevCanvas) + love.graphics.setShader() + love.graphics.setBlendMode(prevBlendMode) + love.graphics.draw(canvas1, x, y) + + -- Restore graphics state + love.graphics.setShader(prevShader) + love.graphics.setColor(unpack(prevColor)) + + -- Release canvases back to cache + releaseCanvas(canvas1) + releaseCanvas(canvas2) +end + +--- Clear canvas cache (call on window resize) +function Blur.clearCache() + canvasCache = {} +end + +return Blur diff --git a/flexlove/Color.lua b/flexlove/Color.lua new file mode 100644 index 0000000..0bfb141 --- /dev/null +++ b/flexlove/Color.lua @@ -0,0 +1,80 @@ +--[[ +Color.lua - Color utility class for FlexLove +Provides color handling with RGB/RGBA support and hex string conversion +]] + +-- ==================== +-- Error Handling Utilities +-- ==================== + +--- Standardized error message formatter +---@param module string -- Module name (e.g., "Color", "Theme", "Units") +---@param message string -- Error message +---@return string -- Formatted error message +local function formatError(module, message) + return string.format("[FlexLove.%s] %s", module, message) +end + +-- ==================== +-- Color System +-- ==================== + +--- Utility class for color handling +---@class Color +---@field r number -- Red component (0-1) +---@field g number -- Green component (0-1) +---@field b number -- Blue component (0-1) +---@field a number -- Alpha component (0-1) +local Color = {} +Color.__index = Color + +--- Create a new color instance +---@param r number? -- Default: 0 +---@param g number? -- Default: 0 +---@param b number? -- Default: 0 +---@param a number? -- Default: 1 +---@return Color +function Color.new(r, g, b, a) + local self = setmetatable({}, Color) + self.r = r or 0 + self.g = g or 0 + self.b = b or 0 + self.a = a or 1 + return self +end + +---@return number r, number g, number b, number a +function Color:toRGBA() + return self.r, self.g, self.b, self.a +end + +--- Convert hex string to color +--- Supports both 6-digit (#RRGGBB) and 8-digit (#RRGGBBAA) hex formats +---@param hexWithTag string -- e.g. "#RRGGBB" or "#RRGGBBAA" +---@return Color +---@throws Error if hex string format is invalid +function Color.fromHex(hexWithTag) + local hex = hexWithTag:gsub("#", "") + if #hex == 6 then + local r = tonumber("0x" .. hex:sub(1, 2)) + local g = tonumber("0x" .. hex:sub(3, 4)) + local b = tonumber("0x" .. hex:sub(5, 6)) + if not r or not g or not b then + error(formatError("Color", string.format("Invalid hex string format: '%s'. Contains invalid hex digits", hexWithTag))) + end + return Color.new(r / 255, g / 255, b / 255, 1) + elseif #hex == 8 then + local r = tonumber("0x" .. hex:sub(1, 2)) + local g = tonumber("0x" .. hex:sub(3, 4)) + local b = tonumber("0x" .. hex:sub(5, 6)) + local a = tonumber("0x" .. hex:sub(7, 8)) + if not r or not g or not b or not a then + error(formatError("Color", string.format("Invalid hex string format: '%s'. Contains invalid hex digits", hexWithTag))) + end + return Color.new(r / 255, g / 255, b / 255, a / 255) + else + error(formatError("Color", string.format("Invalid hex string format: '%s'. Expected #RRGGBB or #RRGGBBAA", hexWithTag))) + end +end + +return Color diff --git a/flexlove/Element.lua b/flexlove/Element.lua new file mode 100644 index 0000000..cfb8e9d --- /dev/null +++ b/flexlove/Element.lua @@ -0,0 +1,4103 @@ +-- ==================== +-- Element Object +-- ==================== + +--[[ +INTERNAL FIELD NAMING CONVENTIONS: +--------------------------------- +Fields prefixed with underscore (_) are internal/private and should not be accessed directly: + +- _pressed: Internal state tracking for mouse button presses +- _lastClickTime: Internal timestamp for double-click detection +- _lastClickButton: Internal button tracking for click events +- _clickCount: Internal counter for multi-click detection +- _touchPressed: Internal touch state tracking +- _themeState: Internal current theme state (managed automatically) +- _borderBoxWidth: Internal cached border-box width (optimization) +- _borderBoxHeight: Internal cached border-box height (optimization) +- _explicitlyAbsolute: Internal flag for positioning logic +- _originalPositioning: Internal original positioning value +- _cachedResult: Internal animation cache (Animation class) +- _resultDirty: Internal animation dirty flag (Animation class) +- _loadedAtlas: Internal cached atlas image (ThemeComponent) +- _cachedViewport: Internal viewport cache (Gui class) + +Public API methods to access internal state: +- Element:getBorderBoxWidth() - Get border-box width +- Element:getBorderBoxHeight() - Get border-box height +- Element:getBounds() - Get element bounds +]] + +---@class Element +---@field id string +---@field autosizing {width:boolean, height:boolean} -- Whether the element should automatically size to fit its children +---@field x number|string -- X coordinate of the element +---@field y number|string -- Y coordinate of the element +---@field z number -- Z-index for layering (default: 0) +---@field width number|string -- Width of the element +---@field height number|string -- Height of the element +---@field top number? -- Offset from top edge (CSS-style positioning) +---@field right number? -- Offset from right edge (CSS-style positioning) +---@field bottom number? -- Offset from bottom edge (CSS-style positioning) +---@field left number? -- Offset from left edge (CSS-style positioning) +---@field children table -- Children of this element +---@field parent Element? -- Parent element (nil if top-level) +---@field border Border -- Border configuration for the element +---@field opacity number +---@field borderColor Color -- Color of the border +---@field backgroundColor Color -- Background color of the element +---@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius for rounded corners (default: 0) +---@field prevGameSize {width:number, height:number} -- Previous game size for resize calculations +---@field text string? -- Text content to display in the element +---@field textColor Color -- Color of the text content +---@field textAlign TextAlign -- Alignment of the text content +---@field gap number|string -- Space between children elements (default: 10) +---@field padding {top?:number, right?:number, bottom?:number, left?:number}? -- Padding around children (default: {top=0, right=0, bottom=0, left=0}) +---@field margin {top?:number, right?:number, bottom?:number, left?:number} -- Margin around children (default: {top=0, right=0, bottom=0, left=0}) +---@field positioning Positioning -- Layout positioning mode (default: RELATIVE) +---@field flexDirection FlexDirection -- Direction of flex layout (default: HORIZONTAL) +---@field justifyContent JustifyContent -- Alignment of items along main axis (default: FLEX_START) +---@field alignItems AlignItems -- Alignment of items along cross axis (default: STRETCH) +---@field alignContent AlignContent -- Alignment of lines in multi-line flex containers (default: STRETCH) +---@field flexWrap FlexWrap -- Whether children wrap to multiple lines (default: NOWRAP) +---@field justifySelf JustifySelf -- Alignment of the item itself along main axis (default: AUTO) +---@field alignSelf AlignSelf -- Alignment of the item itself along cross axis (default: AUTO) +---@field textSize number? -- Resolved font size for text content in pixels +---@field minTextSize number? +---@field maxTextSize number? +---@field fontFamily string? -- Font family name from theme or path to font file +---@field autoScaleText boolean -- Whether text should auto-scale with window size (default: true) +---@field transform TransformProps -- Transform properties for animations and styling +---@field transition TransitionProps -- Transition settings for animations +---@field callback fun(element:Element, event:InputEvent)? -- Callback function for interaction events +---@field units table -- Original unit specifications for responsive behavior +---@field _pressed table -- Track pressed state per mouse button +---@field _lastClickTime number? -- Timestamp of last click for double-click detection +---@field _lastClickButton number? -- Button of last click +---@field _clickCount number -- Current click count for multi-click detection +---@field _touchPressed table -- Track touch pressed state +---@field _dragStartX table? -- Track drag start X position per mouse button +---@field _dragStartY table? -- Track drag start Y position per mouse button +---@field _lastMouseX table? -- Last known mouse X position per button for drag tracking +---@field _lastMouseY table? -- Last known mouse Y position per button for drag tracking +---@field _explicitlyAbsolute boolean? +---@field gridRows number? -- Number of rows in the grid +---@field gridColumns number? -- Number of columns in the grid +---@field columnGap number|string? -- Gap between grid columns +---@field rowGap number|string? -- Gap between grid rows +---@field theme string? -- Theme component to use for rendering +---@field themeComponent string? +---@field _themeState string? -- Current theme state (normal, hover, pressed, active, disabled) +---@field disabled boolean? -- Whether the element is disabled (default: false) +---@field active boolean? -- Whether the element is active/focused (for inputs, default: false) +---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false) +---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions +---@field scaleCorners number? -- Scale multiplier for 9-slice corners/edges. E.g., 2 = 2x size (overrides theme setting) +---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-slice corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting) +---@field contentBlur {intensity:number, quality:number}? -- Blur the element's content including children (intensity: 0-100, quality: 1-10) +---@field backdropBlur {intensity:number, quality:number}? -- Blur content behind the element (intensity: 0-100, quality: 1-10) +---@field _blurInstance table? -- Internal: cached blur effect instance +---@field editable boolean -- Whether the element is editable (default: false) +---@field multiline boolean -- Whether the element supports multiple lines (default: false) +---@field textWrap boolean|"word"|"char" -- Text wrapping mode (default: false for single-line, "word" for multi-line) +---@field maxLines number? -- Maximum number of lines (default: nil) +---@field maxLength number? -- Maximum text length in characters (default: nil) +---@field placeholder string? -- Placeholder text when empty (default: nil) +---@field passwordMode boolean -- Whether to display text as password (default: false) +---@field inputType "text"|"number"|"email"|"url" -- Input type for validation (default: "text") +---@field textOverflow "clip"|"ellipsis"|"scroll" -- Text overflow behavior (default: "clip") +---@field scrollable boolean -- Whether text is scrollable (default: false for single-line, true for multi-line) +---@field autoGrow boolean -- Whether element auto-grows with text (default: false) +---@field selectOnFocus boolean -- Whether to select all text on focus (default: false) +---@field cursorColor Color? -- Cursor color (default: nil, uses textColor) +---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default) +---@field cursorBlinkRate number -- Cursor blink rate in seconds (default: 0.5) +---@field _cursorPosition number? -- Internal: cursor character position (0-based) +---@field _cursorLine number? -- Internal: cursor line number (1-based) +---@field _cursorColumn number? -- Internal: cursor column within line +---@field _cursorBlinkTimer number? -- Internal: cursor blink timer +---@field _cursorVisible boolean? -- Internal: cursor visibility state +---@field _selectionStart number? -- Internal: selection start position +---@field _selectionEnd number? -- Internal: selection end position +---@field _selectionAnchor number? -- Internal: selection anchor point +---@field _focused boolean? -- Internal: focus state +---@field _textBuffer string? -- Internal: text buffer for editable elements +---@field _lines table? -- Internal: split lines for multi-line text +---@field _wrappedLines table? -- Internal: wrapped line data +---@field _textDirty boolean? -- Internal: flag to recalculate lines/wrapping +---@field imagePath string? -- Path to image file (auto-loads via ImageCache) +---@field image love.Image? -- Image object to display +---@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 _loadedImage love.Image? -- Internal: cached loaded image +local Element = {} +Element.__index = Element + +---@param props ElementProps +---@return Element +function Element.new(props) + local self = setmetatable({}, Element) + self.children = {} + self.callback = props.callback + self.id = props.id or "" + + -- Input event callbacks + self.onFocus = props.onFocus + self.onBlur = props.onBlur + self.onTextInput = props.onTextInput + self.onTextChange = props.onTextChange + self.onEnter = props.onEnter + + -- Initialize click tracking for event system + self._pressed = {} -- Track pressed state per mouse button + self._lastClickTime = nil + self._lastClickButton = nil + self._clickCount = 0 + self._touchPressed = {} + + -- Initialize drag tracking for event system + self._dragStartX = {} -- Track drag start X position per mouse button + self._dragStartY = {} -- Track drag start Y position per mouse button + self._lastMouseX = {} -- Track last mouse X position per button + self._lastMouseY = {} -- Track last mouse Y position per button + + -- Initialize theme + self._themeState = "normal" + + -- Handle theme property: + -- - theme: which theme to use (defaults to Gui.defaultTheme if not specified) + -- - themeComponent: which component from the theme (e.g., "panel", "button", "input") + -- If themeComponent is nil, no theme is applied (manual styling) + self.theme = props.theme or Gui.defaultTheme + self.themeComponent = props.themeComponent or nil + + -- Initialize state properties + self.disabled = props.disabled or false + self.active = props.active or false + + -- disableHighlight defaults to true when using themeComponent (themes handle their own visual feedback) + -- Can be explicitly overridden by setting props.disableHighlight + if props.disableHighlight ~= nil then + self.disableHighlight = props.disableHighlight + else + self.disableHighlight = self.themeComponent ~= nil + end + + -- Initialize contentAutoSizingMultiplier after theme is set + -- Priority: element props > theme component > theme default + if props.contentAutoSizingMultiplier then + -- Explicitly set on element + self.contentAutoSizingMultiplier = props.contentAutoSizingMultiplier + else + -- Try to source from theme + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse then + -- First check if themeComponent has a multiplier + if self.themeComponent then + local component = themeToUse.components[self.themeComponent] + if component and component.contentAutoSizingMultiplier then + self.contentAutoSizingMultiplier = component.contentAutoSizingMultiplier + elseif themeToUse.contentAutoSizingMultiplier then + -- Fall back to theme default + self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier + else + self.contentAutoSizingMultiplier = { 1, 1 } + end + elseif themeToUse.contentAutoSizingMultiplier then + self.contentAutoSizingMultiplier = themeToUse.contentAutoSizingMultiplier + else + self.contentAutoSizingMultiplier = { 1, 1 } + end + else + self.contentAutoSizingMultiplier = { 1, 1 } + end + end + + -- Initialize 9-slice corner scaling properties + -- These override theme component settings when specified + self.scaleCorners = props.scaleCorners + self.scalingAlgorithm = props.scalingAlgorithm + + -- Initialize blur properties + self.contentBlur = props.contentBlur + self.backdropBlur = props.backdropBlur + self._blurInstance = nil + + -- Initialize input control properties + self.editable = props.editable or false + self.multiline = props.multiline or false + self.passwordMode = props.passwordMode or false + + -- Validate property combinations: passwordMode disables multiline + if self.passwordMode then + self.multiline = false + end + + self.textWrap = props.textWrap + if self.textWrap == nil then + self.textWrap = self.multiline and "word" or false + end + + self.maxLines = props.maxLines + self.maxLength = props.maxLength + self.placeholder = props.placeholder + self.inputType = props.inputType or "text" + + -- Text behavior properties + self.textOverflow = props.textOverflow or "clip" + self.scrollable = props.scrollable + if self.scrollable == nil then + self.scrollable = self.multiline + end + self.autoGrow = props.autoGrow or false + self.selectOnFocus = props.selectOnFocus or false + + -- Cursor and selection properties + self.cursorColor = props.cursorColor + self.selectionColor = props.selectionColor + self.cursorBlinkRate = props.cursorBlinkRate or 0.5 + + -- Initialize cursor and selection state (only if editable) + if self.editable then + self._cursorPosition = 0 -- Character index (0 = before first char) + self._cursorLine = 1 -- Current line number (1-based) + self._cursorColumn = 0 -- Column within current line + self._cursorBlinkTimer = 0 + self._cursorVisible = true + + -- Selection state + self._selectionStart = nil -- nil = no selection + self._selectionEnd = nil + self._selectionAnchor = nil -- Anchor point for shift+arrow selection + + -- Focus state + self._focused = false + + -- Text buffer state (initialized after self.text is set below) + self._textBuffer = props.text or "" -- Actual text content + self._lines = nil -- Split lines (for multiline) + self._wrappedLines = nil -- Wrapped line data + self._textDirty = true -- Flag to recalculate lines/wrapping + end + + -- Set parent first so it's available for size calculations + self.parent = props.parent + + ------ add non-hereditary ------ + --- self drawing--- + self.border = props.border + and { + top = props.border.top or false, + right = props.border.right or false, + bottom = props.border.bottom or false, + left = props.border.left or false, + } + or { + top = false, + right = false, + bottom = false, + left = false, + } + self.borderColor = props.borderColor or Color.new(0, 0, 0, 1) + self.backgroundColor = props.backgroundColor or Color.new(0, 0, 0, 0) + self.opacity = props.opacity or 1 + + -- Handle cornerRadius (can be number or table) + if props.cornerRadius then + if type(props.cornerRadius) == "number" then + self.cornerRadius = { + topLeft = props.cornerRadius, + topRight = props.cornerRadius, + bottomLeft = props.cornerRadius, + bottomRight = props.cornerRadius, + } + else + self.cornerRadius = { + topLeft = props.cornerRadius.topLeft or 0, + topRight = props.cornerRadius.topRight or 0, + bottomLeft = props.cornerRadius.bottomLeft or 0, + bottomRight = props.cornerRadius.bottomRight or 0, + } + end + else + self.cornerRadius = { + topLeft = 0, + topRight = 0, + bottomLeft = 0, + bottomRight = 0, + } + end + + self.text = props.text + self.textAlign = props.textAlign or TextAlign.START + + -- Image properties + self.imagePath = props.imagePath + self.image = props.image + self.objectFit = props.objectFit or "fill" + self.objectPosition = props.objectPosition or "center center" + self.imageOpacity = props.imageOpacity or 1 + + -- Auto-load image if imagePath is provided + if self.imagePath and not self.image then + local loadedImage, err = ImageCache.load(self.imagePath) + if loadedImage then + self._loadedImage = loadedImage + else + -- Silently fail - image will just not render + self._loadedImage = nil + end + elseif self.image then + self._loadedImage = self.image + else + self._loadedImage = nil + end + + --- self positioning --- + local viewportWidth, viewportHeight = Units.getViewport() + + ---- Sizing ---- + local gw, gh = love.window.getMode() + self.prevGameSize = { width = gw, height = gh } + self.autosizing = { width = false, height = false } + + -- Store unit specifications for responsive behavior + self.units = { + width = { value = nil, unit = "px" }, + height = { value = nil, unit = "px" }, + x = { value = nil, unit = "px" }, + y = { value = nil, unit = "px" }, + textSize = { value = nil, unit = "px" }, + gap = { value = nil, unit = "px" }, + padding = { + top = { value = nil, unit = "px" }, + right = { value = nil, unit = "px" }, + bottom = { value = nil, unit = "px" }, + left = { value = nil, unit = "px" }, + horizontal = { value = nil, unit = "px" }, -- Shorthand for left/right + vertical = { value = nil, unit = "px" }, -- Shorthand for top/bottom + }, + margin = { + top = { value = nil, unit = "px" }, + right = { value = nil, unit = "px" }, + bottom = { value = nil, unit = "px" }, + left = { value = nil, unit = "px" }, + horizontal = { value = nil, unit = "px" }, -- Shorthand for left/right + vertical = { value = nil, unit = "px" }, -- Shorthand for top/bottom + }, + } + + -- Get scale factors from Gui (will be used later) + local scaleX, scaleY = Gui.getScaleFactors() + + -- Store original textSize units and constraints + self.minTextSize = props.minTextSize + self.maxTextSize = props.maxTextSize + + -- Set autoScaleText BEFORE textSize processing (needed for correct initialization) + if props.autoScaleText == nil then + self.autoScaleText = true + else + self.autoScaleText = props.autoScaleText + end + + -- Handle fontFamily (can be font name from theme or direct path to font file) + -- Priority: explicit props.fontFamily > parent fontFamily > theme default + if props.fontFamily then + -- Explicitly set fontFamily takes highest priority + self.fontFamily = props.fontFamily + elseif self.parent and self.parent.fontFamily then + -- Inherit from parent if parent has fontFamily set + self.fontFamily = self.parent.fontFamily + elseif props.themeComponent then + -- If using themeComponent, try to get default from theme + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.fonts and themeToUse.fonts["default"] then + self.fontFamily = "default" + else + self.fontFamily = nil + end + else + self.fontFamily = nil + end + + -- Handle textSize BEFORE width/height calculation (needed for auto-sizing) + if props.textSize then + if type(props.textSize) == "string" then + -- Check if it's a preset first + local presetValue, presetUnit = resolveTextSizePreset(props.textSize) + local value, unit + + if presetValue then + -- It's a preset, use the preset value and unit + value, unit = presetValue, presetUnit + self.units.textSize = { value = value, unit = unit } + else + -- Not a preset, parse normally + value, unit = Units.parse(props.textSize) + self.units.textSize = { value = value, unit = unit } + end + + -- Resolve textSize based on unit type + if unit == "%" or unit == "vh" then + -- Percentage and vh are relative to viewport height + self.textSize = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) + elseif unit == "vw" then + -- vw is relative to viewport width + self.textSize = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) + elseif unit == "ew" then + -- ew is relative to element width (use viewport width as fallback during initialization) + -- Will be re-resolved after width is set + self.textSize = (value / 100) * viewportWidth + elseif unit == "eh" then + -- eh is relative to element height (use viewport height as fallback during initialization) + -- Will be re-resolved after height is set + self.textSize = (value / 100) * viewportHeight + elseif unit == "px" then + -- Pixel units + self.textSize = value + else + error("Unknown textSize unit: " .. unit) + end + else + -- Validate pixel textSize value + if props.textSize <= 0 then + error("textSize must be greater than 0, got: " .. tostring(props.textSize)) + end + + -- Pixel textSize value + if self.autoScaleText and Gui.baseScale then + -- With base scaling: store original pixel value and scale relative to base resolution + self.units.textSize = { value = props.textSize, unit = "px" } + self.textSize = props.textSize * scaleY + elseif self.autoScaleText then + -- Without base scaling: convert to viewport units for auto-scaling + -- Calculate what percentage of viewport height this represents + local vhValue = (props.textSize / viewportHeight) * 100 + self.units.textSize = { value = vhValue, unit = "vh" } + self.textSize = props.textSize -- Initial size is the specified pixel value + else + -- No auto-scaling: apply base scaling if set, otherwise use raw value + self.textSize = Gui.baseScale and (props.textSize * scaleY) or props.textSize + self.units.textSize = { value = props.textSize, unit = "px" } + end + end + else + -- No textSize specified - use auto-scaling default + if self.autoScaleText and Gui.baseScale then + -- With base scaling: use 12px as default and scale + self.units.textSize = { value = 12, unit = "px" } + self.textSize = 12 * scaleY + elseif self.autoScaleText then + -- Without base scaling: default to 1.5vh (1.5% of viewport height) + self.units.textSize = { value = 1.5, unit = "vh" } + self.textSize = (1.5 / 100) * viewportHeight + else + -- No auto-scaling: use 12px with optional base scaling + self.textSize = Gui.baseScale and (12 * scaleY) or 12 + self.units.textSize = { value = nil, unit = "px" } + end + end + + -- Handle width (both w and width properties, prefer w if both exist) + local widthProp = props.width + local tempWidth = 0 -- Temporary width for padding resolution + if widthProp then + if type(widthProp) == "string" then + local value, unit = Units.parse(widthProp) + self.units.width = { value = value, unit = unit } + local parentWidth = self.parent and self.parent.width or viewportWidth + tempWidth = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) + else + -- Apply base scaling to pixel values + tempWidth = Gui.baseScale and (widthProp * scaleX) or widthProp + self.units.width = { value = widthProp, unit = "px" } + end + self.width = tempWidth + else + self.autosizing.width = true + -- Calculate auto-width without padding first + tempWidth = self:calculateAutoWidth() + self.width = tempWidth + self.units.width = { value = nil, unit = "auto" } -- Mark as auto-sized + end + + -- Handle height (both h and height properties, prefer h if both exist) + local heightProp = props.height + local tempHeight = 0 -- Temporary height for padding resolution + if heightProp then + if type(heightProp) == "string" then + local value, unit = Units.parse(heightProp) + self.units.height = { value = value, unit = unit } + local parentHeight = self.parent and self.parent.height or viewportHeight + tempHeight = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) + else + -- Apply base scaling to pixel values + tempHeight = Gui.baseScale and (heightProp * scaleY) or heightProp + self.units.height = { value = heightProp, unit = "px" } + end + self.height = tempHeight + else + self.autosizing.height = true + -- Calculate auto-height without padding first + tempHeight = self:calculateAutoHeight() + self.height = tempHeight + self.units.height = { value = nil, unit = "auto" } -- Mark as auto-sized + end + + --- child positioning --- + if props.gap then + if type(props.gap) == "string" then + local value, unit = Units.parse(props.gap) + self.units.gap = { value = value, unit = unit } + -- Gap percentages should be relative to the element's own size, not parent + -- For horizontal flex, gap is based on width; for vertical flex, based on height + local flexDir = props.flexDirection or FlexDirection.HORIZONTAL + local containerSize = (flexDir == FlexDirection.HORIZONTAL) and self.width or self.height + self.gap = Units.resolve(value, unit, viewportWidth, viewportHeight, containerSize) + else + self.gap = props.gap + self.units.gap = { value = props.gap, unit = "px" } + end + else + self.gap = 0 + self.units.gap = { value = 0, unit = "px" } + end + + -- BORDER-BOX MODEL: For auto-sizing, we need to add padding to content dimensions + -- For explicit sizing, width/height already include padding (border-box) + + -- Check if we should use 9-patch content padding for auto-sizing + local use9PatchPadding = false + local ninePatchContentPadding = nil + if self.themeComponent then + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.components[self.themeComponent] then + local component = themeToUse.components[self.themeComponent] + if component._ninePatchData and component._ninePatchData.contentPadding then + -- Only use 9-patch padding if no explicit padding was provided + if + not props.padding + or ( + not props.padding.top + and not props.padding.right + and not props.padding.bottom + and not props.padding.left + and not props.padding.horizontal + and not props.padding.vertical + ) + then + use9PatchPadding = true + ninePatchContentPadding = component._ninePatchData.contentPadding + end + end + end + end + + -- First, resolve padding using temporary dimensions + -- For auto-sized elements, this is content width; for explicit sizing, this is border-box width + local tempPadding + if use9PatchPadding then + -- Scale 9-patch content padding to match the actual rendered size + -- The contentPadding values are in the original image's pixel coordinates, + -- but we need to scale them proportionally to the element's actual size + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.components[self.themeComponent] then + local component = themeToUse.components[self.themeComponent] + local atlasImage = component._loadedAtlas or themeToUse.atlas + + if atlasImage and type(atlasImage) ~= "string" then + local originalWidth, originalHeight = atlasImage:getDimensions() + + -- Calculate the scale factor based on the element's border-box size vs original image size + -- For explicit sizing, tempWidth/tempHeight represent the border-box dimensions + local scaleX = tempWidth / originalWidth + local scaleY = tempHeight / originalHeight + + tempPadding = { + left = ninePatchContentPadding.left * scaleX, + top = ninePatchContentPadding.top * scaleY, + right = ninePatchContentPadding.right * scaleX, + bottom = ninePatchContentPadding.bottom * scaleY, + } + else + -- Fallback if atlas image not available + tempPadding = { + left = ninePatchContentPadding.left, + top = ninePatchContentPadding.top, + right = ninePatchContentPadding.right, + bottom = ninePatchContentPadding.bottom, + } + end + else + -- Fallback if theme not found + tempPadding = { + left = ninePatchContentPadding.left, + top = ninePatchContentPadding.top, + right = ninePatchContentPadding.right, + bottom = ninePatchContentPadding.bottom, + } + end + else + tempPadding = Units.resolveSpacing(props.padding, self.width, self.height) + end + + -- Margin percentages are relative to parent's dimensions (CSS spec) + local parentWidth = self.parent and self.parent.width or viewportWidth + local parentHeight = self.parent and self.parent.height or viewportHeight + self.margin = Units.resolveSpacing(props.margin, parentWidth, parentHeight) + + -- For auto-sized elements, add padding to get border-box dimensions + if self.autosizing.width then + self._borderBoxWidth = self.width + tempPadding.left + tempPadding.right + else + -- For explicit sizing, width is already border-box + self._borderBoxWidth = self.width + end + + if self.autosizing.height then + self._borderBoxHeight = self.height + tempPadding.top + tempPadding.bottom + else + -- For explicit sizing, height is already border-box + self._borderBoxHeight = self.height + end + + -- Set final padding + if use9PatchPadding then + -- Use 9-patch content padding + self.padding = { + left = ninePatchContentPadding.left, + top = ninePatchContentPadding.top, + right = ninePatchContentPadding.right, + bottom = ninePatchContentPadding.bottom, + } + else + -- Re-resolve padding based on final border-box dimensions (important for percentage padding) + self.padding = Units.resolveSpacing(props.padding, self._borderBoxWidth, self._borderBoxHeight) + end + + -- Calculate final content dimensions by subtracting padding from border-box + self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) + self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) + + -- Re-resolve ew/eh textSize units now that width/height are set + if props.textSize and type(props.textSize) == "string" then + local value, unit = Units.parse(props.textSize) + if unit == "ew" then + -- Element width relative (now that width is set) + self.textSize = (value / 100) * self.width + elseif unit == "eh" then + -- Element height relative (now that height is set) + self.textSize = (value / 100) * self.height + end + end + + -- Apply min/max constraints (also scaled) + local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize) + local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) + + if minSize and self.textSize < minSize then + self.textSize = minSize + end + if maxSize and self.textSize > maxSize then + self.textSize = maxSize + end + + -- Protect against too-small text sizes (minimum 1px) + if self.textSize < 1 then + self.textSize = 1 -- Minimum 1px + end + + -- Store original spacing values for proper resize handling + -- Store shorthand properties first (horizontal/vertical) + if props.padding then + if props.padding.horizontal then + if type(props.padding.horizontal) == "string" then + local value, unit = Units.parse(props.padding.horizontal) + self.units.padding.horizontal = { value = value, unit = unit } + else + self.units.padding.horizontal = { value = props.padding.horizontal, unit = "px" } + end + end + if props.padding.vertical then + if type(props.padding.vertical) == "string" then + local value, unit = Units.parse(props.padding.vertical) + self.units.padding.vertical = { value = value, unit = unit } + else + self.units.padding.vertical = { value = props.padding.vertical, unit = "px" } + end + end + end + + -- Initialize all padding sides + for _, side in ipairs({ "top", "right", "bottom", "left" }) do + if props.padding and props.padding[side] then + if type(props.padding[side]) == "string" then + local value, unit = Units.parse(props.padding[side]) + self.units.padding[side] = { value = value, unit = unit, explicit = true } + else + self.units.padding[side] = { value = props.padding[side], unit = "px", explicit = true } + end + else + -- Mark as derived from shorthand (will use shorthand during resize if available) + self.units.padding[side] = { value = self.padding[side], unit = "px", explicit = false } + end + end + + -- Store margin shorthand properties + if props.margin then + if props.margin.horizontal then + if type(props.margin.horizontal) == "string" then + local value, unit = Units.parse(props.margin.horizontal) + self.units.margin.horizontal = { value = value, unit = unit } + else + self.units.margin.horizontal = { value = props.margin.horizontal, unit = "px" } + end + end + if props.margin.vertical then + if type(props.margin.vertical) == "string" then + local value, unit = Units.parse(props.margin.vertical) + self.units.margin.vertical = { value = value, unit = unit } + else + self.units.margin.vertical = { value = props.margin.vertical, unit = "px" } + end + end + end + + -- Initialize all margin sides + for _, side in ipairs({ "top", "right", "bottom", "left" }) do + if props.margin and props.margin[side] then + if type(props.margin[side]) == "string" then + local value, unit = Units.parse(props.margin[side]) + self.units.margin[side] = { value = value, unit = unit, explicit = true } + else + self.units.margin[side] = { value = props.margin[side], unit = "px", explicit = true } + end + else + -- Mark as derived from shorthand (will use shorthand during resize if available) + self.units.margin[side] = { value = self.margin[side], unit = "px", explicit = false } + end + end + + -- Grid properties are set later in the constructor + + ------ add hereditary ------ + if props.parent == nil then + table.insert(Gui.topElements, self) + + -- Handle x position with units + if props.x then + if type(props.x) == "string" then + local value, unit = Units.parse(props.x) + self.units.x = { value = value, unit = unit } + self.x = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) + else + -- Apply base scaling to pixel positions + self.x = Gui.baseScale and (props.x * scaleX) or props.x + self.units.x = { value = props.x, unit = "px" } + end + else + self.x = 0 + self.units.x = { value = 0, unit = "px" } + end + + -- Handle y position with units + if props.y then + if type(props.y) == "string" then + local value, unit = Units.parse(props.y) + self.units.y = { value = value, unit = unit } + self.y = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) + else + -- Apply base scaling to pixel positions + self.y = Gui.baseScale and (props.y * scaleY) or props.y + self.units.y = { value = props.y, unit = "px" } + end + else + self.y = 0 + self.units.y = { value = 0, unit = "px" } + end + + self.z = props.z or 0 + + -- Set textColor with priority: props > theme text color > black + if props.textColor then + self.textColor = props.textColor + else + -- Try to get text color from theme + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.colors and themeToUse.colors.text then + self.textColor = themeToUse.colors.text + else + -- Fallback to black + self.textColor = Color.new(0, 0, 0, 1) + end + end + + -- Track if positioning was explicitly set + if props.positioning then + self.positioning = props.positioning + self._originalPositioning = props.positioning + self._explicitlyAbsolute = (props.positioning == Positioning.ABSOLUTE) + else + self.positioning = Positioning.RELATIVE + self._originalPositioning = nil -- No explicit positioning + self._explicitlyAbsolute = false + end + else + -- Set positioning first and track if explicitly set + self._originalPositioning = props.positioning -- Track original intent + if props.positioning == Positioning.ABSOLUTE then + self.positioning = Positioning.ABSOLUTE + self._explicitlyAbsolute = true -- Explicitly set to absolute by user + elseif props.positioning == Positioning.FLEX then + self.positioning = Positioning.FLEX + self._explicitlyAbsolute = false + elseif props.positioning == Positioning.GRID then + self.positioning = Positioning.GRID + self._explicitlyAbsolute = false + else + -- Default: children in flex/grid containers participate in parent's layout + -- children in relative/absolute containers default to relative + if self.parent.positioning == Positioning.FLEX or self.parent.positioning == Positioning.GRID then + self.positioning = Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid + self._explicitlyAbsolute = false -- Participate in parent's layout + else + self.positioning = Positioning.RELATIVE + self._explicitlyAbsolute = false -- Default for relative/absolute containers + end + end + + -- Set initial position + if self.positioning == Positioning.ABSOLUTE then + -- Handle x position with units + if props.x then + if type(props.x) == "string" then + local value, unit = Units.parse(props.x) + self.units.x = { value = value, unit = unit } + local parentWidth = self.parent.width + self.x = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) + else + -- Apply base scaling to pixel positions + self.x = Gui.baseScale and (props.x * scaleX) or props.x + self.units.x = { value = props.x, unit = "px" } + end + else + self.x = 0 + self.units.x = { value = 0, unit = "px" } + end + + -- Handle y position with units + if props.y then + if type(props.y) == "string" then + local value, unit = Units.parse(props.y) + self.units.y = { value = value, unit = unit } + local parentHeight = self.parent.height + self.y = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) + else + -- Apply base scaling to pixel positions + self.y = Gui.baseScale and (props.y * scaleY) or props.y + self.units.y = { value = props.y, unit = "px" } + end + else + self.y = 0 + self.units.y = { value = 0, unit = "px" } + end + + self.z = props.z or 0 + else + -- Children in flex containers start at parent position but will be repositioned by layoutChildren + local baseX = self.parent.x + local baseY = self.parent.y + + if props.x then + if type(props.x) == "string" then + local value, unit = Units.parse(props.x) + self.units.x = { value = value, unit = unit } + local parentWidth = self.parent.width + local offsetX = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) + self.x = baseX + offsetX + else + -- Apply base scaling to pixel offsets + local scaledOffset = Gui.baseScale and (props.x * scaleX) or props.x + self.x = baseX + scaledOffset + self.units.x = { value = props.x, unit = "px" } + end + else + self.x = baseX + self.units.x = { value = 0, unit = "px" } + end + + if props.y then + if type(props.y) == "string" then + local value, unit = Units.parse(props.y) + self.units.y = { value = value, unit = unit } + local parentHeight = self.parent.height + local offsetY = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) + self.y = baseY + offsetY + else + -- Apply base scaling to pixel offsets + local scaledOffset = Gui.baseScale and (props.y * scaleY) or props.y + self.y = baseY + scaledOffset + self.units.y = { value = props.y, unit = "px" } + end + else + self.y = baseY + self.units.y = { value = 0, unit = "px" } + end + + self.z = props.z or self.parent.z or 0 + end + + -- Set textColor with priority: props > parent > theme text color > black + if props.textColor then + self.textColor = props.textColor + elseif self.parent.textColor then + self.textColor = self.parent.textColor + else + -- Try to get text color from theme + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.colors and themeToUse.colors.text then + self.textColor = themeToUse.colors.text + else + -- Fallback to black + self.textColor = Color.new(0, 0, 0, 1) + end + end + + props.parent:addChild(self) + end + + -- Handle positioning properties for ALL elements (with or without parent) + -- Handle top positioning with units + if props.top then + if type(props.top) == "string" then + local value, unit = Units.parse(props.top) + self.units.top = { value = value, unit = unit } + self.top = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) + else + self.top = props.top + self.units.top = { value = props.top, unit = "px" } + end + else + self.top = nil + self.units.top = nil + end + + -- Handle right positioning with units + if props.right then + if type(props.right) == "string" then + local value, unit = Units.parse(props.right) + self.units.right = { value = value, unit = unit } + self.right = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) + else + self.right = props.right + self.units.right = { value = props.right, unit = "px" } + end + else + self.right = nil + self.units.right = nil + end + + -- Handle bottom positioning with units + if props.bottom then + if type(props.bottom) == "string" then + local value, unit = Units.parse(props.bottom) + self.units.bottom = { value = value, unit = unit } + self.bottom = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportHeight) + else + self.bottom = props.bottom + self.units.bottom = { value = props.bottom, unit = "px" } + end + else + self.bottom = nil + self.units.bottom = nil + end + + -- Handle left positioning with units + if props.left then + if type(props.left) == "string" then + local value, unit = Units.parse(props.left) + self.units.left = { value = value, unit = unit } + self.left = Units.resolve(value, unit, viewportWidth, viewportHeight, viewportWidth) + else + self.left = props.left + self.units.left = { value = props.left, unit = "px" } + end + else + self.left = nil + self.units.left = nil + end + + if self.positioning == Positioning.FLEX then + self.flexDirection = props.flexDirection or FlexDirection.HORIZONTAL + self.flexWrap = props.flexWrap or FlexWrap.NOWRAP + self.justifyContent = props.justifyContent or JustifyContent.FLEX_START + self.alignItems = props.alignItems or AlignItems.STRETCH + self.alignContent = props.alignContent or AlignContent.STRETCH + self.justifySelf = props.justifySelf or JustifySelf.AUTO + end + + -- Grid container properties + if self.positioning == Positioning.GRID then + self.gridRows = props.gridRows or 1 + self.gridColumns = props.gridColumns or 1 + self.alignItems = props.alignItems or AlignItems.STRETCH + + -- Handle columnGap and rowGap + if props.columnGap then + if type(props.columnGap) == "string" then + local value, unit = Units.parse(props.columnGap) + self.columnGap = Units.resolve(value, unit, viewportWidth, viewportHeight, self.width) + else + self.columnGap = props.columnGap + end + else + self.columnGap = 0 + end + + if props.rowGap then + if type(props.rowGap) == "string" then + local value, unit = Units.parse(props.rowGap) + self.rowGap = Units.resolve(value, unit, viewportWidth, viewportHeight, self.height) + else + self.rowGap = props.rowGap + end + else + self.rowGap = 0 + end + end + + self.alignSelf = props.alignSelf or AlignSelf.AUTO + + ---animation + self.transform = props.transform or {} + self.transition = props.transition or {} + + -- Overflow and scroll properties + self.overflow = props.overflow or "visible" + self.overflowX = props.overflowX + self.overflowY = props.overflowY + + -- Scrollbar configuration + self.scrollbarWidth = props.scrollbarWidth or 12 + self.scrollbarColor = props.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8) + self.scrollbarTrackColor = props.scrollbarTrackColor or Color.new(0.2, 0.2, 0.2, 0.5) + self.scrollbarRadius = props.scrollbarRadius or 6 + self.scrollbarPadding = props.scrollbarPadding or 2 + self.scrollSpeed = props.scrollSpeed or 20 + + -- Internal overflow state + self._overflowX = false + self._overflowY = false + self._contentWidth = 0 + self._contentHeight = 0 + + -- Scroll state + self._scrollX = 0 + self._scrollY = 0 + self._maxScrollX = 0 + self._maxScrollY = 0 + + -- Scrollbar interaction state + self._scrollbarHovered = false + self._scrollbarDragging = false + self._hoveredScrollbar = nil -- "vertical" or "horizontal" + self._scrollbarDragOffset = 0 -- Offset from thumb top when drag started + + return self +end + +--- Get element bounds (content box) +---@return { x:number, y:number, width:number, height:number } +function Element:getBounds() + return { x = self.x, y = self.y, width = self:getBorderBoxWidth(), height = self:getBorderBoxHeight() } +end + +--- Check if point is inside element bounds +--- @param x number +--- @param y number +--- @return boolean +function Element:contains(x, y) + local bounds = self:getBounds() + return bounds.x <= x and bounds.y <= y and bounds.x + bounds.width >= x and bounds.y + bounds.height >= y +end + +--- Get border-box width (including padding) +---@return number +function Element:getBorderBoxWidth() + return self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) +end + +--- Get border-box height (including padding) +---@return number +function Element:getBorderBoxHeight() + return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) +end + +--- Detect if content overflows container bounds +function Element:_detectOverflow() + -- Reset overflow state + self._overflowX = false + self._overflowY = false + self._contentWidth = self.width + self._contentHeight = self.height + + -- Skip detection if overflow is visible (no clipping needed) + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + if overflowX == "visible" and overflowY == "visible" then + return + end + + -- Calculate content bounds based on children + if #self.children == 0 then + return -- No children, no overflow + end + + local minX, minY = math.huge, math.huge + local maxX, maxY = -math.huge, -math.huge + + for _, child in ipairs(self.children) do + -- Skip absolutely positioned children (they don't contribute to overflow) + if not child._explicitlyAbsolute then + local childLeft = child.x - self.x + local childTop = child.y - self.y + local childRight = childLeft + child:getBorderBoxWidth() + child.margin.left + child.margin.right + local childBottom = childTop + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + + minX = math.min(minX, childLeft) + minY = math.min(minY, childTop) + maxX = math.max(maxX, childRight) + maxY = math.max(maxY, childBottom) + end + end + + -- If no non-absolute children, no overflow + if minX == math.huge then + return + end + + -- Calculate content dimensions + self._contentWidth = math.max(0, maxX - minX) + self._contentHeight = math.max(0, maxY - minY) + + -- Detect overflow + local containerWidth = self.width + local containerHeight = self.height + + self._overflowX = self._contentWidth > containerWidth + self._overflowY = self._contentHeight > containerHeight + + -- Calculate maximum scroll bounds + self._maxScrollX = math.max(0, self._contentWidth - containerWidth) + self._maxScrollY = math.max(0, self._contentHeight - containerHeight) + + -- Clamp current scroll position to new bounds + self._scrollX = math.max(0, math.min(self._scrollX, self._maxScrollX)) + self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY)) +end + +--- Set scroll position with bounds clamping +---@param x number? -- X scroll position (nil to keep current) +---@param y number? -- Y scroll position (nil to keep current) +function Element:setScrollPosition(x, y) + if x ~= nil then + self._scrollX = math.max(0, math.min(x, self._maxScrollX)) + end + if y ~= nil then + self._scrollY = math.max(0, math.min(y, self._maxScrollY)) + end +end + +--- Calculate scrollbar dimensions and positions +---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}} +function Element:_calculateScrollbarDimensions() + local result = { + vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, + horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, + } + + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + -- Vertical scrollbar + if self._overflowY and (overflowY == "scroll" or overflowY == "auto") then + result.vertical.visible = true + result.vertical.trackHeight = self.height - (self.scrollbarPadding * 2) + + -- Calculate thumb height based on content ratio + local contentRatio = self.height / math.max(self._contentHeight, self.height) + result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) + + -- Calculate thumb position based on scroll ratio + local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 + local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight + result.vertical.thumbY = maxThumbY * scrollRatio + elseif overflowY == "scroll" then + -- Always show scrollbar for "scroll" mode even without overflow + result.vertical.visible = true + result.vertical.trackHeight = self.height - (self.scrollbarPadding * 2) + result.vertical.thumbHeight = result.vertical.trackHeight + result.vertical.thumbY = 0 + end + + -- Horizontal scrollbar + if self._overflowX and (overflowX == "scroll" or overflowX == "auto") then + result.horizontal.visible = true + result.horizontal.trackWidth = self.width - (self.scrollbarPadding * 2) + + -- Calculate thumb width based on content ratio + local contentRatio = self.width / math.max(self._contentWidth, self.width) + result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) + + -- Calculate thumb position based on scroll ratio + local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 + local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth + result.horizontal.thumbX = maxThumbX * scrollRatio + elseif overflowX == "scroll" then + -- Always show scrollbar for "scroll" mode even without overflow + result.horizontal.visible = true + result.horizontal.trackWidth = self.width - (self.scrollbarPadding * 2) + result.horizontal.thumbWidth = result.horizontal.trackWidth + result.horizontal.thumbX = 0 + end + + return result +end + +--- Draw scrollbars +---@param dims table -- Scrollbar dimensions from _calculateScrollbarDimensions() +function Element:_drawScrollbars(dims) + local x, y = self.x, self.y + local w, h = self.width, self.height + + -- Determine thumb color based on state + local thumbColor = self.scrollbarColor + if self._scrollbarDragging then + -- Active state: brighter + thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a) + elseif self._scrollbarHovered then + -- Hover state: slightly brighter + thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a) + end + + -- Vertical scrollbar + if dims.vertical.visible then + local trackX = x + w - self.scrollbarWidth - self.scrollbarPadding + self.padding.left + local trackY = y + self.scrollbarPadding + self.padding.top + + -- Draw track + love.graphics.setColor(self.scrollbarTrackColor:toRGBA()) + love.graphics.rectangle("fill", trackX, trackY, self.scrollbarWidth, dims.vertical.trackHeight, self.scrollbarRadius) + + -- Draw thumb with state-based color + love.graphics.setColor(thumbColor:toRGBA()) + love.graphics.rectangle("fill", trackX, trackY + dims.vertical.thumbY, self.scrollbarWidth, dims.vertical.thumbHeight, self.scrollbarRadius) + end + + -- Horizontal scrollbar + if dims.horizontal.visible then + local trackX = x + self.scrollbarPadding + self.padding.left + local trackY = y + h - self.scrollbarWidth - self.scrollbarPadding + self.padding.top + + -- Draw track + love.graphics.setColor(self.scrollbarTrackColor:toRGBA()) + love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, self.scrollbarWidth, self.scrollbarRadius) + + -- Draw thumb with state-based color + love.graphics.setColor(thumbColor:toRGBA()) + love.graphics.rectangle("fill", trackX + dims.horizontal.thumbX, trackY, dims.horizontal.thumbWidth, self.scrollbarWidth, self.scrollbarRadius) + end + + -- Reset color + love.graphics.setColor(1, 1, 1, 1) +end + +--- Get scrollbar at mouse position +---@param mouseX number +---@param mouseY number +---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"} +function Element:_getScrollbarAtPosition(mouseX, mouseY) + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then + return nil + end + + local dims = self:_calculateScrollbarDimensions() + local x, y = self.x, self.y + local w, h = self.width, self.height + + -- Check vertical scrollbar + if dims.vertical.visible then + local trackX = x + w - self.scrollbarWidth - self.scrollbarPadding + self.padding.left + local trackY = y + self.scrollbarPadding + self.padding.top + local trackW = self.scrollbarWidth + local trackH = dims.vertical.trackHeight + + if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then + -- Check if over thumb + local thumbY = trackY + dims.vertical.thumbY + local thumbH = dims.vertical.thumbHeight + if mouseY >= thumbY and mouseY <= thumbY + thumbH then + return { component = "vertical", region = "thumb" } + else + return { component = "vertical", region = "track" } + end + end + end + + -- Check horizontal scrollbar + if dims.horizontal.visible then + local trackX = x + self.scrollbarPadding + self.padding.left + local trackY = y + h - self.scrollbarWidth - self.scrollbarPadding + self.padding.top + local trackW = dims.horizontal.trackWidth + local trackH = self.scrollbarWidth + + if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then + -- Check if over thumb + local thumbX = trackX + dims.horizontal.thumbX + local thumbW = dims.horizontal.thumbWidth + if mouseX >= thumbX and mouseX <= thumbX + thumbW then + return { component = "horizontal", region = "thumb" } + else + return { component = "horizontal", region = "track" } + end + end + end + + return nil +end + +--- Handle scrollbar mouse press +---@param mouseX number +---@param mouseY number +---@param button number +---@return boolean -- True if event was consumed +function Element:_handleScrollbarPress(mouseX, mouseY, button) + if button ~= 1 then + return false + end -- Only left click + + local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY) + if not scrollbar then + return false + end + + if scrollbar.region == "thumb" then + -- Start dragging thumb + self._scrollbarDragging = true + self._hoveredScrollbar = scrollbar.component + local dims = self:_calculateScrollbarDimensions() + + if scrollbar.component == "vertical" then + local trackY = self.y + self.scrollbarPadding + self.padding.top + local thumbY = trackY + dims.vertical.thumbY + self._scrollbarDragOffset = mouseY - thumbY + elseif scrollbar.component == "horizontal" then + local trackX = self.x + self.scrollbarPadding + self.padding.left + local thumbX = trackX + dims.horizontal.thumbX + self._scrollbarDragOffset = mouseX - thumbX + end + + return true -- Event consumed + elseif scrollbar.region == "track" then + -- Click on track - jump to position + self:_scrollToTrackPosition(mouseX, mouseY, scrollbar.component) + return true + end + + return false +end + +--- Handle scrollbar drag +---@param mouseX number +---@param mouseY number +---@return boolean -- True if event was consumed +function Element:_handleScrollbarDrag(mouseX, mouseY) + if not self._scrollbarDragging then + return false + end + + local dims = self:_calculateScrollbarDimensions() + + if self._hoveredScrollbar == "vertical" then + local trackY = self.y + self.scrollbarPadding + self.padding.top + local trackH = dims.vertical.trackHeight + local thumbH = dims.vertical.thumbHeight + + -- Calculate new thumb position + local newThumbY = mouseY - self._scrollbarDragOffset - trackY + newThumbY = math.max(0, math.min(newThumbY, trackH - thumbH)) + + -- Convert thumb position to scroll position + local scrollRatio = (trackH - thumbH) > 0 and (newThumbY / (trackH - thumbH)) or 0 + local newScrollY = scrollRatio * self._maxScrollY + + self:setScrollPosition(nil, newScrollY) + return true + elseif self._hoveredScrollbar == "horizontal" then + local trackX = self.x + self.scrollbarPadding + self.padding.left + local trackW = dims.horizontal.trackWidth + local thumbW = dims.horizontal.thumbWidth + + -- Calculate new thumb position + local newThumbX = mouseX - self._scrollbarDragOffset - trackX + newThumbX = math.max(0, math.min(newThumbX, trackW - thumbW)) + + -- Convert thumb position to scroll position + local scrollRatio = (trackW - thumbW) > 0 and (newThumbX / (trackW - thumbW)) or 0 + local newScrollX = scrollRatio * self._maxScrollX + + self:setScrollPosition(newScrollX, nil) + return true + end + + return false +end + +--- Handle scrollbar release +---@param button number +---@return boolean -- True if event was consumed +function Element:_handleScrollbarRelease(button) + if button ~= 1 then + return false + end + + if self._scrollbarDragging then + self._scrollbarDragging = false + return true + end + + return false +end + +--- Scroll to track click position +---@param mouseX number +---@param mouseY number +---@param component string -- "vertical" or "horizontal" +function Element:_scrollToTrackPosition(mouseX, mouseY, component) + local dims = self:_calculateScrollbarDimensions() + + if component == "vertical" then + local trackY = self.y + self.scrollbarPadding + self.padding.top + local trackH = dims.vertical.trackHeight + local thumbH = dims.vertical.thumbHeight + + -- Calculate target thumb position (centered on click) + local targetThumbY = mouseY - trackY - (thumbH / 2) + targetThumbY = math.max(0, math.min(targetThumbY, trackH - thumbH)) + + -- Convert to scroll position + local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0 + local newScrollY = scrollRatio * self._maxScrollY + + self:setScrollPosition(nil, newScrollY) + elseif component == "horizontal" then + local trackX = self.x + self.scrollbarPadding + self.padding.left + local trackW = dims.horizontal.trackWidth + local thumbW = dims.horizontal.thumbWidth + + -- Calculate target thumb position (centered on click) + local targetThumbX = mouseX - trackX - (thumbW / 2) + targetThumbX = math.max(0, math.min(targetThumbX, trackW - thumbW)) + + -- Convert to scroll position + local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0 + local newScrollX = scrollRatio * self._maxScrollX + + self:setScrollPosition(newScrollX, nil) + end +end + +--- Handle mouse wheel scrolling +---@param x number -- Horizontal scroll amount +---@param y number -- Vertical scroll amount +---@return boolean -- True if scroll was handled +function Element:_handleWheelScroll(x, y) + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then + return false + end + + local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0 + local hasHorizontalOverflow = self._overflowX and self._maxScrollX > 0 + + local scrolled = false + + -- Vertical scrolling + if y ~= 0 and hasVerticalOverflow then + local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up + local newScrollY = self._scrollY + delta + self:setScrollPosition(nil, newScrollY) + scrolled = true + end + + -- Horizontal scrolling + if x ~= 0 and hasHorizontalOverflow then + local delta = -x * self.scrollSpeed + local newScrollX = self._scrollX + delta + self:setScrollPosition(newScrollX, nil) + scrolled = true + end + + return scrolled +end + +--- Get current scroll position +---@return number scrollX, number scrollY +function Element:getScrollPosition() + return self._scrollX, self._scrollY +end + +--- Get maximum scroll bounds +---@return number maxScrollX, number maxScrollY +function Element:getMaxScroll() + return self._maxScrollX, self._maxScrollY +end + +--- Get scroll percentage (0-1) +---@return number percentX, number percentY +function Element:getScrollPercentage() + local percentX = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 + local percentY = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 + return percentX, percentY +end + +--- Check if element has overflow +---@return boolean hasOverflowX, boolean hasOverflowY +function Element:hasOverflow() + return self._overflowX, self._overflowY +end + +--- Get content dimensions (including overflow) +---@return number contentWidth, number contentHeight +function Element:getContentSize() + return self._contentWidth, self._contentHeight +end + +--- Scroll by delta amount +---@param dx number? -- X delta (nil for no change) +---@param dy number? -- Y delta (nil for no change) +function Element:scrollBy(dx, dy) + if dx then + self._scrollX = math.max(0, math.min(self._scrollX + dx, self._maxScrollX)) + end + if dy then + self._scrollY = math.max(0, math.min(self._scrollY + dy, self._maxScrollY)) + end +end + +--- Scroll to top +function Element:scrollToTop() + self:setScrollPosition(nil, 0) +end + +--- Scroll to bottom +function Element:scrollToBottom() + self:setScrollPosition(nil, self._maxScrollY) +end + +--- Scroll to left +function Element:scrollToLeft() + self:setScrollPosition(0, nil) +end + +--- Scroll to right +function Element:scrollToRight() + self:setScrollPosition(self._maxScrollX, nil) +end + +--- Get the current state's scaled content padding +--- Returns the contentPadding for the current theme state, scaled to the element's size +---@return table|nil -- {left, top, right, bottom} or nil if no contentPadding +function Element:getScaledContentPadding() + if not self.themeComponent then + return nil + end + + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if not themeToUse or not themeToUse.components[self.themeComponent] then + return nil + end + + local component = themeToUse.components[self.themeComponent] + + -- Check for state-specific override + local state = self._themeState or "normal" + if state and state ~= "normal" and component.states and component.states[state] then + component = component.states[state] + end + + if not component._ninePatchData or not component._ninePatchData.contentPadding then + return nil + end + + local contentPadding = component._ninePatchData.contentPadding + + -- Scale contentPadding to match the actual rendered size + local atlasImage = component._loadedAtlas or themeToUse.atlas + if atlasImage and type(atlasImage) ~= "string" then + local originalWidth, originalHeight = atlasImage:getDimensions() + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + local scaleX = borderBoxWidth / originalWidth + local scaleY = borderBoxHeight / originalHeight + + return { + left = contentPadding.left * scaleX, + top = contentPadding.top * scaleY, + right = contentPadding.right * scaleX, + bottom = contentPadding.bottom * scaleY, + } + else + -- Return unscaled values as fallback + return { + left = contentPadding.left, + top = contentPadding.top, + right = contentPadding.right, + bottom = contentPadding.bottom, + } + end +end + +--- Get or create blur instance for this element +---@return table? -- Blur instance or nil if no blur configured +function Element:getBlurInstance() + -- Determine quality from contentBlur or backdropBlur + local quality = 5 -- Default quality + if self.contentBlur and self.contentBlur.quality then + quality = self.contentBlur.quality + elseif self.backdropBlur and self.backdropBlur.quality then + quality = self.backdropBlur.quality + end + + -- Create blur instance if needed + if not self._blurInstance or self._blurInstance.quality ~= quality then + self._blurInstance = Blur.new(quality) + end + + return self._blurInstance +end + +--- Get available content width for children (accounting for 9-slice content padding) +--- This is the width that children should use when calculating percentage widths +---@return number +function Element:getAvailableContentWidth() + local availableWidth = self.width + + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + -- Check if the element is using the scaled 9-patch contentPadding as its padding + -- Allow small floating point differences (within 0.1 pixels) + local usingContentPaddingAsPadding = ( + math.abs(self.padding.left - scaledContentPadding.left) < 0.1 and math.abs(self.padding.right - scaledContentPadding.right) < 0.1 + ) + + if not usingContentPaddingAsPadding then + -- Element has explicit padding different from contentPadding + -- Subtract scaled contentPadding to get the area children should use + availableWidth = availableWidth - scaledContentPadding.left - scaledContentPadding.right + end + end + + return math.max(0, availableWidth) +end + +--- Get available content height for children (accounting for 9-slice content padding) +--- This is the height that children should use when calculating percentage heights +---@return number +function Element:getAvailableContentHeight() + local availableHeight = self.height + + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + -- Check if the element is using the scaled 9-patch contentPadding as its padding + -- Allow small floating point differences (within 0.1 pixels) + local usingContentPaddingAsPadding = ( + math.abs(self.padding.top - scaledContentPadding.top) < 0.1 and math.abs(self.padding.bottom - scaledContentPadding.bottom) < 0.1 + ) + + if not usingContentPaddingAsPadding then + -- Element has explicit padding different from contentPadding + -- Subtract scaled contentPadding to get the area children should use + availableHeight = availableHeight - scaledContentPadding.top - scaledContentPadding.bottom + end + end + + return math.max(0, availableHeight) +end + +--- Add child to element +---@param child Element +function Element:addChild(child) + child.parent = self + + -- Re-evaluate positioning now that we have a parent + -- If child was created without explicit positioning, inherit from parent + if child._originalPositioning == nil then + -- No explicit positioning was set during construction + if self.positioning == Positioning.FLEX or self.positioning == Positioning.GRID then + child.positioning = Positioning.ABSOLUTE -- They are positioned BY flex/grid, not AS flex/grid + child._explicitlyAbsolute = false -- Participate in parent's layout + else + child.positioning = Positioning.RELATIVE + child._explicitlyAbsolute = false -- Default for relative/absolute containers + end + end + -- If child._originalPositioning is set, it means explicit positioning was provided + -- and _explicitlyAbsolute was already set correctly during construction + + table.insert(self.children, child) + + -- Only recalculate auto-sizing if the child participates in layout + -- (CSS: absolutely positioned children don't affect parent auto-sizing) + if not child._explicitlyAbsolute then + if self.autosizing.height then + local contentHeight = self:calculateAutoHeight() + -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content + self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom + self.height = contentHeight + end + if self.autosizing.width then + local contentWidth = self:calculateAutoWidth() + -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content + self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right + self.width = contentWidth + end + end + + self:layoutChildren() +end + +--- Apply positioning offsets (top, right, bottom, left) to an element +-- @param element The element to apply offsets to +function Element:applyPositioningOffsets(element) + if not element then + return + end + + -- For CSS-style positioning, we need the parent's bounds + local parent = element.parent + if not parent then + return + end + + -- Only apply offsets to explicitly absolute children or children in relative/absolute containers + -- Flex/grid children ignore positioning offsets as they participate in layout + local isFlexChild = element.positioning == Positioning.FLEX + or element.positioning == Positioning.GRID + or (element.positioning == Positioning.ABSOLUTE and not element._explicitlyAbsolute) + + if not isFlexChild then + -- Apply absolute positioning for explicitly absolute children + -- Apply top offset (distance from parent's content box top edge) + if element.top then + element.y = parent.y + parent.padding.top + element.top + end + + -- Apply bottom offset (distance from parent's content box bottom edge) + -- BORDER-BOX MODEL: Use border-box dimensions for positioning + if element.bottom then + local elementBorderBoxHeight = element:getBorderBoxHeight() + element.y = parent.y + parent.padding.top + parent.height - element.bottom - elementBorderBoxHeight + end + + -- Apply left offset (distance from parent's content box left edge) + if element.left then + element.x = parent.x + parent.padding.left + element.left + end + + -- Apply right offset (distance from parent's content box right edge) + -- BORDER-BOX MODEL: Use border-box dimensions for positioning + if element.right then + local elementBorderBoxWidth = element:getBorderBoxWidth() + element.x = parent.x + parent.padding.left + parent.width - element.right - elementBorderBoxWidth + end + end +end + +function Element:layoutChildren() + if self.positioning == Positioning.ABSOLUTE or self.positioning == Positioning.RELATIVE then + -- Absolute/Relative positioned containers don't layout their children according to flex rules, + -- but they should still apply CSS positioning offsets to their children + for _, child in ipairs(self.children) do + if child.top or child.right or child.bottom or child.left then + self:applyPositioningOffsets(child) + end + end + return + end + + -- Handle grid layout + if self.positioning == Positioning.GRID then + Grid.layoutGridItems(self) + return + end + + local childCount = #self.children + + if childCount == 0 then + return + end + + -- Get flex children (children that participate in flex layout) + local flexChildren = {} + for _, child in ipairs(self.children) do + local isFlexChild = not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) + if isFlexChild then + table.insert(flexChildren, child) + end + end + + if #flexChildren == 0 then + return + end + + -- Calculate space reserved by absolutely positioned siblings with explicit positioning + local reservedMainStart = 0 -- Space reserved at the start of main axis (left for horizontal, top for vertical) + local reservedMainEnd = 0 -- Space reserved at the end of main axis (right for horizontal, bottom for vertical) + local reservedCrossStart = 0 -- Space reserved at the start of cross axis (top for horizontal, left for vertical) + local reservedCrossEnd = 0 -- Space reserved at the end of cross axis (bottom for horizontal, right for vertical) + + for _, child in ipairs(self.children) do + -- Only consider absolutely positioned children with explicit positioning + if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then + -- BORDER-BOX MODEL: Use border-box dimensions for space calculations + local childBorderBoxWidth = child:getBorderBoxWidth() + local childBorderBoxHeight = child:getBorderBoxHeight() + + if self.flexDirection == FlexDirection.HORIZONTAL then + -- Horizontal layout: main axis is X, cross axis is Y + -- Check for left positioning (reserves space at main axis start) + if child.left then + local spaceNeeded = child.left + childBorderBoxWidth + reservedMainStart = math.max(reservedMainStart, spaceNeeded) + end + -- Check for right positioning (reserves space at main axis end) + if child.right then + local spaceNeeded = child.right + childBorderBoxWidth + reservedMainEnd = math.max(reservedMainEnd, spaceNeeded) + end + -- Check for top positioning (reserves space at cross axis start) + if child.top then + local spaceNeeded = child.top + childBorderBoxHeight + reservedCrossStart = math.max(reservedCrossStart, spaceNeeded) + end + -- Check for bottom positioning (reserves space at cross axis end) + if child.bottom then + local spaceNeeded = child.bottom + childBorderBoxHeight + reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded) + end + else + -- Vertical layout: main axis is Y, cross axis is X + -- Check for top positioning (reserves space at main axis start) + if child.top then + local spaceNeeded = child.top + childBorderBoxHeight + reservedMainStart = math.max(reservedMainStart, spaceNeeded) + end + -- Check for bottom positioning (reserves space at main axis end) + if child.bottom then + local spaceNeeded = child.bottom + childBorderBoxHeight + reservedMainEnd = math.max(reservedMainEnd, spaceNeeded) + end + -- Check for left positioning (reserves space at cross axis start) + if child.left then + local spaceNeeded = child.left + childBorderBoxWidth + reservedCrossStart = math.max(reservedCrossStart, spaceNeeded) + end + -- Check for right positioning (reserves space at cross axis end) + if child.right then + local spaceNeeded = child.right + childBorderBoxWidth + reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded) + end + end + end + end + + -- Calculate available space (accounting for padding and reserved space) + -- BORDER-BOX MODEL: self.width and self.height are already content dimensions (padding subtracted) + local availableMainSize = 0 + local availableCrossSize = 0 + if self.flexDirection == FlexDirection.HORIZONTAL then + availableMainSize = self.width - reservedMainStart - reservedMainEnd + availableCrossSize = self.height - reservedCrossStart - reservedCrossEnd + else + availableMainSize = self.height - reservedMainStart - reservedMainEnd + availableCrossSize = self.width - reservedCrossStart - reservedCrossEnd + end + + -- Handle flex wrap: create lines of children + local lines = {} + + if self.flexWrap == FlexWrap.NOWRAP then + -- All children go on one line + lines[1] = flexChildren + else + -- Wrap children into multiple lines + local currentLine = {} + local currentLineSize = 0 + + for _, child in ipairs(flexChildren) do + -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations + -- Include margins in size calculations + local childMainSize = 0 + local childMainMargin = 0 + if self.flexDirection == FlexDirection.HORIZONTAL then + childMainSize = child:getBorderBoxWidth() + childMainMargin = child.margin.left + child.margin.right + else + childMainSize = child:getBorderBoxHeight() + childMainMargin = child.margin.top + child.margin.bottom + end + local childTotalMainSize = childMainSize + childMainMargin + + -- Check if adding this child would exceed the available space + local lineSpacing = #currentLine > 0 and self.gap or 0 + if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableMainSize then + -- Start a new line + if #currentLine > 0 then + table.insert(lines, currentLine) + end + currentLine = { child } + currentLineSize = childTotalMainSize + else + -- Add to current line + table.insert(currentLine, child) + currentLineSize = currentLineSize + lineSpacing + childTotalMainSize + end + end + + -- Add the last line if it has children + if #currentLine > 0 then + table.insert(lines, currentLine) + end + + -- Handle wrap-reverse: reverse the order of lines + if self.flexWrap == FlexWrap.WRAP_REVERSE then + local reversedLines = {} + for i = #lines, 1, -1 do + table.insert(reversedLines, lines[i]) + end + lines = reversedLines + end + end + + -- Calculate line positions and heights (including child padding) + local lineHeights = {} + local totalLinesHeight = 0 + + for lineIndex, line in ipairs(lines) do + local maxCrossSize = 0 + for _, child in ipairs(line) do + -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations + -- Include margins in cross-axis size calculations + local childCrossSize = 0 + local childCrossMargin = 0 + if self.flexDirection == FlexDirection.HORIZONTAL then + childCrossSize = child:getBorderBoxHeight() + childCrossMargin = child.margin.top + child.margin.bottom + else + childCrossSize = child:getBorderBoxWidth() + childCrossMargin = child.margin.left + child.margin.right + end + local childTotalCrossSize = childCrossSize + childCrossMargin + maxCrossSize = math.max(maxCrossSize, childTotalCrossSize) + end + lineHeights[lineIndex] = maxCrossSize + totalLinesHeight = totalLinesHeight + maxCrossSize + end + + -- Account for gaps between lines + local lineGaps = math.max(0, #lines - 1) * self.gap + totalLinesHeight = totalLinesHeight + lineGaps + + -- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size + if #lines == 1 then + if self.alignItems == AlignItems.STRETCH or self.alignItems == AlignItems.CENTER or self.alignItems == AlignItems.FLEX_END then + -- STRETCH, CENTER, and FLEX_END should use full available cross size + lineHeights[1] = availableCrossSize + totalLinesHeight = availableCrossSize + end + -- CENTER and FLEX_END should preserve natural child dimensions + -- and only affect positioning within the available space + end + + -- Calculate starting position for lines based on alignContent + local lineStartPos = 0 + local lineSpacing = self.gap + local freeLineSpace = availableCrossSize - totalLinesHeight + + -- Apply AlignContent logic for both single and multiple lines + if self.alignContent == AlignContent.FLEX_START then + lineStartPos = 0 + elseif self.alignContent == AlignContent.CENTER then + lineStartPos = freeLineSpace / 2 + elseif self.alignContent == AlignContent.FLEX_END then + lineStartPos = freeLineSpace + elseif self.alignContent == AlignContent.SPACE_BETWEEN then + lineStartPos = 0 + if #lines > 1 then + lineSpacing = self.gap + (freeLineSpace / (#lines - 1)) + end + elseif self.alignContent == AlignContent.SPACE_AROUND then + local spaceAroundEach = freeLineSpace / #lines + lineStartPos = spaceAroundEach / 2 + lineSpacing = self.gap + spaceAroundEach + elseif self.alignContent == AlignContent.STRETCH then + lineStartPos = 0 + if #lines > 1 and freeLineSpace > 0 then + lineSpacing = self.gap + (freeLineSpace / #lines) + -- Distribute extra space to line heights (only if positive) + local extraPerLine = freeLineSpace / #lines + for i = 1, #lineHeights do + lineHeights[i] = lineHeights[i] + extraPerLine + end + end + end + + -- Position children within each line + local currentCrossPos = lineStartPos + + for lineIndex, line in ipairs(lines) do + local lineHeight = lineHeights[lineIndex] + + -- Calculate total size of children in this line (including padding and margins) + -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations + local totalChildrenSize = 0 + for _, child in ipairs(line) do + if self.flexDirection == FlexDirection.HORIZONTAL then + totalChildrenSize = totalChildrenSize + child:getBorderBoxWidth() + child.margin.left + child.margin.right + else + totalChildrenSize = totalChildrenSize + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + end + end + + local totalGapSize = math.max(0, #line - 1) * self.gap + local totalContentSize = totalChildrenSize + totalGapSize + local freeSpace = availableMainSize - totalContentSize + + -- Calculate initial position and spacing based on justifyContent + local startPos = 0 + local itemSpacing = self.gap + + if self.justifyContent == JustifyContent.FLEX_START then + startPos = 0 + elseif self.justifyContent == JustifyContent.CENTER then + startPos = freeSpace / 2 + elseif self.justifyContent == JustifyContent.FLEX_END then + startPos = freeSpace + elseif self.justifyContent == JustifyContent.SPACE_BETWEEN then + startPos = 0 + if #line > 1 then + itemSpacing = self.gap + (freeSpace / (#line - 1)) + end + elseif self.justifyContent == JustifyContent.SPACE_AROUND then + local spaceAroundEach = freeSpace / #line + startPos = spaceAroundEach / 2 + itemSpacing = self.gap + spaceAroundEach + elseif self.justifyContent == JustifyContent.SPACE_EVENLY then + local spaceBetween = freeSpace / (#line + 1) + startPos = spaceBetween + itemSpacing = self.gap + spaceBetween + end + + -- Position children in this line + local currentMainPos = startPos + + for _, child in ipairs(line) do + -- Determine effective cross-axis alignment + local effectiveAlign = child.alignSelf + if effectiveAlign == nil or effectiveAlign == AlignSelf.AUTO then + effectiveAlign = self.alignItems + end + + if self.flexDirection == FlexDirection.HORIZONTAL then + -- Horizontal layout: main axis is X, cross axis is Y + -- Position child at border box (x, y represents top-left including padding) + -- Add reservedMainStart and left margin to account for absolutely positioned siblings and margins + child.x = self.x + self.padding.left + reservedMainStart + currentMainPos + child.margin.left + + -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations + local childBorderBoxHeight = child:getBorderBoxHeight() + local childTotalCrossSize = childBorderBoxHeight + child.margin.top + child.margin.bottom + + if effectiveAlign == AlignItems.FLEX_START then + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + child.margin.top + elseif effectiveAlign == AlignItems.CENTER then + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.top + elseif effectiveAlign == AlignItems.FLEX_END then + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.top + elseif effectiveAlign == AlignItems.STRETCH then + -- STRETCH: Only apply if height was not explicitly set + if child.autosizing and child.autosizing.height then + -- STRETCH: Set border-box height to lineHeight minus margins, content area shrinks to fit + local availableHeight = lineHeight - child.margin.top - child.margin.bottom + child._borderBoxHeight = availableHeight + child.height = math.max(0, availableHeight - child.padding.top - child.padding.bottom) + end + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + child.margin.top + end + + -- Apply positioning offsets (top, right, bottom, left) + self:applyPositioningOffsets(child) + + -- If child has children, re-layout them after position change + if #child.children > 0 then + child:layoutChildren() + end + + -- Advance position by child's border-box width plus margins + currentMainPos = currentMainPos + child:getBorderBoxWidth() + child.margin.left + child.margin.right + itemSpacing + else + -- Vertical layout: main axis is Y, cross axis is X + -- Position child at border box (x, y represents top-left including padding) + -- Add reservedMainStart and top margin to account for absolutely positioned siblings and margins + child.y = self.y + self.padding.top + reservedMainStart + currentMainPos + child.margin.top + + -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations + local childBorderBoxWidth = child:getBorderBoxWidth() + local childTotalCrossSize = childBorderBoxWidth + child.margin.left + child.margin.right + + if effectiveAlign == AlignItems.FLEX_START then + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + child.margin.left + elseif effectiveAlign == AlignItems.CENTER then + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.left + elseif effectiveAlign == AlignItems.FLEX_END then + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.left + elseif effectiveAlign == AlignItems.STRETCH then + -- STRETCH: Only apply if width was not explicitly set + if child.autosizing and child.autosizing.width then + -- STRETCH: Set border-box width to lineHeight minus margins, content area shrinks to fit + local availableWidth = lineHeight - child.margin.left - child.margin.right + child._borderBoxWidth = availableWidth + child.width = math.max(0, availableWidth - child.padding.left - child.padding.right) + end + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + child.margin.left + end + + -- Apply positioning offsets (top, right, bottom, left) + self:applyPositioningOffsets(child) + + -- If child has children, re-layout them after position change + if #child.children > 0 then + child:layoutChildren() + end + + -- Advance position by child's border-box height plus margins + currentMainPos = currentMainPos + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + itemSpacing + end + end + + -- Move to next line position + currentCrossPos = currentCrossPos + lineHeight + lineSpacing + end + + -- Position explicitly absolute children after flex layout + for _, child in ipairs(self.children) do + if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then + -- Apply positioning offsets (top, right, bottom, left) + self:applyPositioningOffsets(child) + + -- If child has children, layout them after position change + if #child.children > 0 then + child:layoutChildren() + end + end + end + + -- Detect overflow after children are laid out + self:_detectOverflow() +end + +--- Destroy element and its children +function Element:destroy() + -- Remove from global elements list + for i, win in ipairs(Gui.topElements) do + if win == self then + table.remove(Gui.topElements, i) + break + end + end + + if self.parent then + for i, child in ipairs(self.parent.children) do + if child == self then + table.remove(self.parent.children, i) + break + end + end + self.parent = nil + end + + -- Destroy all children + for _, child in ipairs(self.children) do + child:destroy() + end + + -- Clear children table + self.children = {} + + -- Clear parent reference + if self.parent then + self.parent = nil + end + + -- Clear animation reference + self.animation = nil + + -- Clear callback to prevent closure leaks + self.callback = nil +end + +--- Draw element and its children +function Element:draw(backdropCanvas) + -- Early exit if element is invisible (optimization) + if self.opacity <= 0 then + return + end + + -- Handle opacity during animation + local drawBackgroundColor = self.backgroundColor + if self.animation then + local anim = self.animation:interpolate() + if anim.opacity then + drawBackgroundColor = Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity) + end + end + + -- Cache border box dimensions for this draw call (optimization) + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + + -- 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() + if blurInstance then + Blur.applyBackdrop(blurInstance, self.backdropBlur.intensity, self.x, self.y, borderBoxWidth, borderBoxHeight, backdropCanvas) + end + end + + -- LAYER 1: Draw backgroundColor first (behind everything) + -- Apply opacity to all drawing operations + -- (x, y) represents border box, so draw background from (x, y) + -- BORDER-BOX MODEL: Use stored border-box dimensions for drawing + local backgroundWithOpacity = Color.new(drawBackgroundColor.r, drawBackgroundColor.g, drawBackgroundColor.b, drawBackgroundColor.a * self.opacity) + love.graphics.setColor(backgroundWithOpacity:toRGBA()) + RoundedRect.draw("fill", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) + + -- LAYER 1.5: Draw image on top of backgroundColor (if image exists) + if self._loadedImage then + -- Calculate image bounds (content area - respects padding) + local imageX = self.x + self.padding.left + local imageY = self.y + self.padding.top + local imageWidth = self.width + local imageHeight = self.height + + -- Combine element opacity with imageOpacity + local finalOpacity = self.opacity * self.imageOpacity + + -- Apply cornerRadius clipping if set + local hasCornerRadius = self.cornerRadius.topLeft > 0 + or self.cornerRadius.topRight > 0 + or self.cornerRadius.bottomLeft > 0 + or self.cornerRadius.bottomRight > 0 + + if hasCornerRadius then + -- Use stencil to clip image to rounded corners + love.graphics.stencil(function() + RoundedRect.draw("fill", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) + end, "replace", 1) + love.graphics.setStencilTest("greater", 0) + end + + -- Draw the image + ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity) + + -- Clear stencil if it was used + if hasCornerRadius then + love.graphics.setStencilTest() + end + end + + -- LAYER 2: Draw theme on top of backgroundColor (if theme exists) + if self.themeComponent then + -- Get the theme to use + local themeToUse = nil + if self.theme then + -- Element specifies a specific theme - load it if needed + if themes[self.theme] then + themeToUse = themes[self.theme] + else + -- Try to load the theme + pcall(function() + Theme.load(self.theme) + end) + themeToUse = themes[self.theme] + end + else + -- Use active theme + themeToUse = Theme.getActive() + end + + if themeToUse then + -- Get the component from the theme + local component = themeToUse.components[self.themeComponent] + if component then + -- Check for state-specific override + local state = self._themeState + if state and component.states and component.states[state] then + component = component.states[state] + end + + -- Use component-specific atlas if available, otherwise use theme atlas + local atlasToUse = component._loadedAtlas or themeToUse.atlas + + if atlasToUse and component.regions then + -- Validate component has required structure + local hasAllRegions = component.regions.topLeft + and component.regions.topCenter + and component.regions.topRight + and component.regions.middleLeft + and component.regions.middleCenter + and component.regions.middleRight + and component.regions.bottomLeft + and component.regions.bottomCenter + and component.regions.bottomRight + if hasAllRegions then + -- Calculate border-box dimensions (content + padding) + local borderBoxWidth = self.width + self.padding.left + self.padding.right + local borderBoxHeight = self.height + self.padding.top + self.padding.bottom + -- Pass element-level overrides for scaleCorners and scalingAlgorithm + NineSlice.draw(component, atlasToUse, self.x, self.y, borderBoxWidth, borderBoxHeight, self.opacity, self.scaleCorners, self.scalingAlgorithm) + else + -- Silently skip drawing if component structure is invalid + end + end + else + print("[FlexLove] Component not found: " .. self.themeComponent .. " in theme: " .. themeToUse.name) + end + else + print("[FlexLove] No theme available for themeComponent: " .. self.themeComponent) + end + end + + -- LAYER 3: Draw borders on top of theme (always render if specified) + local borderColorWithOpacity = Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity) + love.graphics.setColor(borderColorWithOpacity:toRGBA()) + + -- Check if all borders are enabled + local allBorders = self.border.top and self.border.bottom and self.border.left and self.border.right + + if allBorders then + -- Draw complete rounded rectangle border + RoundedRect.draw("line", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) + else + -- Draw individual borders (without rounded corners for partial borders) + if self.border.top then + love.graphics.line(self.x, self.y, self.x + borderBoxWidth, self.y) + end + if self.border.bottom then + love.graphics.line(self.x, self.y + borderBoxHeight, self.x + borderBoxWidth, self.y + borderBoxHeight) + end + if self.border.left then + love.graphics.line(self.x, self.y, self.x, self.y + borderBoxHeight) + end + if self.border.right then + love.graphics.line(self.x + borderBoxWidth, self.y, self.x + borderBoxWidth, self.y + borderBoxHeight) + end + end + + -- Draw element text if present + if self.text then + local textColorWithOpacity = Color.new(self.textColor.r, self.textColor.g, self.textColor.b, self.textColor.a * self.opacity) + love.graphics.setColor(textColorWithOpacity:toRGBA()) + + local origFont = love.graphics.getFont() + if self.textSize then + -- Resolve font path from font family + local fontPath = nil + if self.fontFamily then + -- Check if fontFamily is a theme font name + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then + fontPath = themeToUse.fonts[self.fontFamily] + else + -- Treat as direct path to font file + fontPath = self.fontFamily + end + elseif self.themeComponent then + -- If using themeComponent but no fontFamily specified, check for default font in theme + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.fonts and themeToUse.fonts.default then + fontPath = themeToUse.fonts.default + end + end + + -- Use cached font instead of creating new one every frame + local font = FONT_CACHE.get(self.textSize, fontPath) + love.graphics.setFont(font) + end + local font = love.graphics.getFont() + local textWidth = font:getWidth(self.text) + local textHeight = font:getHeight() + local tx, ty + + -- Text is drawn in the content box (inside padding) + -- For 9-slice components, use contentPadding if available + local textPaddingLeft = self.padding.left + local textPaddingTop = self.padding.top + local textAreaWidth = self.width + local textAreaHeight = self.height + + -- Check if we should use 9-slice contentPadding for text positioning + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + + textPaddingLeft = scaledContentPadding.left + textPaddingTop = scaledContentPadding.top + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + textAreaHeight = borderBoxHeight - scaledContentPadding.top - scaledContentPadding.bottom + end + + local contentX = self.x + textPaddingLeft + local contentY = self.y + textPaddingTop + + -- Check if text wrapping is enabled + if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then + -- Use printf for wrapped text + local align = "left" + if self.textAlign == TextAlign.CENTER then + align = "center" + elseif self.textAlign == TextAlign.END then + align = "right" + elseif self.textAlign == TextAlign.JUSTIFY then + align = "justify" + end + + tx = contentX + ty = contentY + + -- Use printf with the available width for wrapping + love.graphics.printf(self.text, tx, ty, textAreaWidth, align) + else + -- Use regular print for non-wrapped text + if self.textAlign == TextAlign.START then + tx = contentX + ty = contentY + elseif self.textAlign == TextAlign.CENTER then + tx = contentX + (textAreaWidth - textWidth) / 2 + ty = contentY + (textAreaHeight - textHeight) / 2 + elseif self.textAlign == TextAlign.END then + tx = contentX + textAreaWidth - textWidth - 10 + ty = contentY + textAreaHeight - textHeight - 10 + elseif self.textAlign == TextAlign.JUSTIFY then + --- need to figure out spreading + tx = contentX + ty = contentY + end + love.graphics.print(self.text, tx, ty) + end + if self.textSize then + love.graphics.setFont(origFont) + end + end + + -- Draw visual feedback when element is pressed (if it has a callback and highlight is not disabled) + if self.callback and not self.disableHighlight then + -- Check if any button is pressed + local anyPressed = false + for _, pressed in pairs(self._pressed) do + if pressed then + anyPressed = true + break + end + end + if anyPressed then + -- BORDER-BOX MODEL: Use stored border-box dimensions for drawing + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * self.opacity) -- Semi-transparent gray for pressed state with opacity + RoundedRect.draw("fill", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) + end + end + + -- Sort children by z-index before drawing + local sortedChildren = {} + for _, child in ipairs(self.children) do + table.insert(sortedChildren, child) + end + table.sort(sortedChildren, function(a, b) + return a.z < b.z + end) + + -- Check if we need to clip children to rounded corners + local hasRoundedCorners = self.cornerRadius.topLeft > 0 + or self.cornerRadius.topRight > 0 + or self.cornerRadius.bottomLeft > 0 + or self.cornerRadius.bottomRight > 0 + + -- Helper function to draw children (with or without clipping) + local function drawChildren() + -- Determine if we need overflow clipping + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible") and (overflowX ~= nil or overflowY ~= nil) + + -- Apply scroll offset if overflow is not visible + local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0) + + if hasScrollOffset then + love.graphics.push() + love.graphics.translate(-self._scrollX, -self._scrollY) + end + + if hasRoundedCorners and #sortedChildren > 0 then + -- Use stencil to clip children to rounded rectangle + -- BORDER-BOX MODEL: Use stored border-box dimensions for clipping + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + local stencilFunc = RoundedRect.stencilFunction(self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) + + love.graphics.stencil(stencilFunc, "replace", 1) + love.graphics.setStencilTest("greater", 0) + + for _, child in ipairs(sortedChildren) do + child:draw(backdropCanvas) + end + + love.graphics.setStencilTest() + elseif needsOverflowClipping and #sortedChildren > 0 then + -- Clip content for overflow hidden/scroll/auto without rounded corners + local contentX = self.x + self.padding.left + local contentY = self.y + self.padding.top + local contentWidth = self.width + local contentHeight = self.height + + love.graphics.setScissor(contentX, contentY, contentWidth, contentHeight) + + for _, child in ipairs(sortedChildren) do + child:draw(backdropCanvas) + end + + love.graphics.setScissor() + else + -- No clipping needed + for _, child in ipairs(sortedChildren) do + child:draw(backdropCanvas) + end + end + + if hasScrollOffset then + love.graphics.pop() + end + end + + -- Apply content blur if configured + if self.contentBlur and self.contentBlur.intensity > 0 and #sortedChildren > 0 then + local blurInstance = self:getBlurInstance() + if blurInstance then + Blur.applyToRegion(blurInstance, self.contentBlur.intensity, self.x, self.y, borderBoxWidth, borderBoxHeight, drawChildren) + else + drawChildren() + end + else + drawChildren() + end + + -- Draw scrollbars if overflow is scroll or auto + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then + local scrollbarDims = self:_calculateScrollbarDimensions() + if scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible then + self:_drawScrollbars(scrollbarDims) + end + end +end + +--- Update element (propagate to children) +---@param dt number +function Element:update(dt) + for _, child in ipairs(self.children) do + child:update(dt) + end + + -- Update cursor blink timer (only if editable and focused) + if self.editable and self._focused then + self._cursorBlinkTimer = self._cursorBlinkTimer + dt + if self._cursorBlinkTimer >= self.cursorBlinkRate then + self._cursorBlinkTimer = 0 + self._cursorVisible = not self._cursorVisible + end + end + + -- Update animation if exists + if self.animation then + local finished = self.animation:update(dt) + if finished then + self.animation = nil -- remove finished animation + else + -- Apply animation interpolation during update + local anim = self.animation:interpolate() + 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.backgroundColor.a = anim.opacity + end + end + end + + -- Handle scrollbar hover detection + local mx, my = love.mouse.getPosition() + local scrollbar = self:_getScrollbarAtPosition(mx, my) + local wasHovered = self._scrollbarHovered + if scrollbar then + self._scrollbarHovered = true + self._hoveredScrollbar = scrollbar.component + else + if not self._scrollbarDragging then + self._scrollbarHovered = false + self._hoveredScrollbar = nil + end + end + + -- Handle scrollbar dragging + if self._scrollbarDragging and love.mouse.isDown(1) then + self:_handleScrollbarDrag(mx, my) + elseif self._scrollbarDragging then + -- Mouse button released + self._scrollbarDragging = false + end + + -- Handle click detection for element with enhanced event system + if self.callback or self.themeComponent then + -- Clickable area is the border box (x, y already includes padding) + -- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection + local bx = self.x + local by = self.y + local bw = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local bh = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + local isHovering = mx >= bx and mx <= bx + bw and my >= by and my <= by + bh + + -- Update theme state based on interaction + if self.themeComponent then + -- Disabled state takes priority + if self.disabled then + self._themeState = "disabled" + -- Active state (for inputs when focused/typing) + elseif self.active then + self._themeState = "active" + elseif isHovering then + -- Check if any button is pressed + local anyPressed = false + for _, pressed in pairs(self._pressed) do + if pressed then + anyPressed = true + break + end + end + + if anyPressed then + self._themeState = "pressed" + else + self._themeState = "hover" + end + else + self._themeState = "normal" + end + end + + -- Only process button events if callback exists, element is not disabled, + -- and this is the topmost element at the mouse position (z-index ordering) + local isActiveElement = (Gui._activeEventElement == nil or Gui._activeEventElement == self) + if self.callback and not self.disabled and isActiveElement then + -- Check all three mouse buttons + local buttons = { 1, 2, 3 } -- left, right, middle + + for _, button in ipairs(buttons) do + if isHovering then + if love.mouse.isDown(button) then + -- Button is pressed down + if not self._pressed[button] then + -- Check if press is on scrollbar first + if button == 1 and self:_handleScrollbarPress(mx, my, button) then + -- Scrollbar consumed the event, mark as pressed to prevent callback + self._pressed[button] = true + else + -- Just pressed - fire press event and record drag start position + local modifiers = getModifiers() + local pressEvent = InputEvent.new({ + type = "press", + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = 1, + }) + self.callback(self, pressEvent) + self._pressed[button] = true + end + + -- Record drag start position per button + self._dragStartX[button] = mx + self._dragStartY[button] = my + self._lastMouseX[button] = mx + self._lastMouseY[button] = my + else + -- Button is still pressed - check for mouse movement (drag) + local lastX = self._lastMouseX[button] or mx + local lastY = self._lastMouseY[button] or my + + if lastX ~= mx or lastY ~= my then + -- Mouse has moved - fire drag event + local modifiers = getModifiers() + local dx = mx - self._dragStartX[button] + local dy = my - self._dragStartY[button] + + local dragEvent = InputEvent.new({ + type = "drag", + button = button, + x = mx, + y = my, + dx = dx, + dy = dy, + modifiers = modifiers, + clickCount = 1, + }) + self.callback(self, dragEvent) + + -- Update last known position for this button + self._lastMouseX[button] = mx + self._lastMouseY[button] = my + end + end + elseif self._pressed[button] then + -- Button was just released - fire click event + local currentTime = love.timer.getTime() + local modifiers = getModifiers() + + -- Determine click count (double-click detection) + local clickCount = 1 + local doubleClickThreshold = 0.3 -- 300ms for double-click + + if self._lastClickTime and self._lastClickButton == button and (currentTime - self._lastClickTime) < doubleClickThreshold then + clickCount = self._clickCount + 1 + else + clickCount = 1 + end + + self._clickCount = clickCount + self._lastClickTime = currentTime + self._lastClickButton = button + + -- Determine event type based on button + local eventType = "click" + if button == 2 then + eventType = "rightclick" + elseif button == 3 then + eventType = "middleclick" + end + + local clickEvent = InputEvent.new({ + type = eventType, + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = clickCount, + }) + + self.callback(self, clickEvent) + self._pressed[button] = false + + -- Clean up drag tracking + self._dragStartX[button] = nil + self._dragStartY[button] = nil + + -- Focus editable elements on left click + if button == 1 and self.editable then + self:focus() + end + + -- Fire release event + local releaseEvent = InputEvent.new({ + type = "release", + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = clickCount, + }) + self.callback(self, releaseEvent) + end + else + -- Mouse left the element - reset pressed state and drag tracking + if self._pressed[button] then + self._pressed[button] = false + self._dragStartX[button] = nil + self._dragStartY[button] = nil + end + end + end + end -- end if self.callback + + -- Handle touch events (maintain backward compatibility) + if self.callback then + local touches = love.touch.getTouches() + for _, id in ipairs(touches) do + local tx, ty = love.touch.getPosition(id) + if tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh then + self._touchPressed[id] = true + elseif self._touchPressed[id] then + -- Create touch event (treat as left click) + local touchEvent = InputEvent.new({ + type = "click", + button = 1, + x = tx, + y = ty, + modifiers = getModifiers(), + clickCount = 1, + }) + self.callback(self, touchEvent) + self._touchPressed[id] = false + end + end + end + end +end + +--- Recalculate units based on new viewport dimensions (for vw, vh, % units) +---@param newViewportWidth number +---@param newViewportHeight number +function Element:recalculateUnits(newViewportWidth, newViewportHeight) + -- Get updated scale factors + local scaleX, scaleY = Gui.getScaleFactors() + + -- Recalculate border-box width if using viewport or percentage units (skip auto-sized) + -- Store in _borderBoxWidth temporarily, will calculate content width after padding is resolved + if self.units.width.unit ~= "px" and self.units.width.unit ~= "auto" then + local parentWidth = self.parent and self.parent.width or newViewportWidth + self._borderBoxWidth = Units.resolve(self.units.width.value, self.units.width.unit, newViewportWidth, newViewportHeight, parentWidth) + elseif self.units.width.unit == "px" and self.units.width.value and Gui.baseScale then + -- Reapply base scaling to pixel widths (border-box) + self._borderBoxWidth = self.units.width.value * scaleX + end + + -- Recalculate border-box height if using viewport or percentage units (skip auto-sized) + -- Store in _borderBoxHeight temporarily, will calculate content height after padding is resolved + if self.units.height.unit ~= "px" and self.units.height.unit ~= "auto" then + local parentHeight = self.parent and self.parent.height or newViewportHeight + self._borderBoxHeight = Units.resolve(self.units.height.value, self.units.height.unit, newViewportWidth, newViewportHeight, parentHeight) + elseif self.units.height.unit == "px" and self.units.height.value and Gui.baseScale then + -- Reapply base scaling to pixel heights (border-box) + self._borderBoxHeight = self.units.height.value * scaleY + end + + -- Recalculate position if using viewport or percentage units + if self.units.x.unit ~= "px" then + local parentWidth = self.parent and self.parent.width or newViewportWidth + local baseX = self.parent and self.parent.x or 0 + local offsetX = Units.resolve(self.units.x.value, self.units.x.unit, newViewportWidth, newViewportHeight, parentWidth) + self.x = baseX + offsetX + else + -- For pixel units, update position relative to parent's new position (with base scaling) + if self.parent then + local baseX = self.parent.x + local scaledOffset = Gui.baseScale and (self.units.x.value * scaleX) or self.units.x.value + self.x = baseX + scaledOffset + elseif Gui.baseScale then + -- Top-level element with pixel position - apply base scaling + self.x = self.units.x.value * scaleX + end + end + + if self.units.y.unit ~= "px" then + local parentHeight = self.parent and self.parent.height or newViewportHeight + local baseY = self.parent and self.parent.y or 0 + local offsetY = Units.resolve(self.units.y.value, self.units.y.unit, newViewportWidth, newViewportHeight, parentHeight) + self.y = baseY + offsetY + else + -- For pixel units, update position relative to parent's new position (with base scaling) + if self.parent then + local baseY = self.parent.y + local scaledOffset = Gui.baseScale and (self.units.y.value * scaleY) or self.units.y.value + self.y = baseY + scaledOffset + elseif Gui.baseScale then + -- Top-level element with pixel position - apply base scaling + self.y = self.units.y.value * scaleY + end + end + + -- Recalculate textSize if auto-scaling is enabled or using viewport/element-relative units + if self.autoScaleText and self.units.textSize.value then + local unit = self.units.textSize.unit + local value = self.units.textSize.value + + if unit == "px" and Gui.baseScale then + -- With base scaling: scale pixel values relative to base resolution + self.textSize = value * scaleY + elseif unit == "px" then + -- Without base scaling but auto-scaling enabled: text doesn't scale + self.textSize = value + elseif unit == "%" or unit == "vh" then + -- Percentage and vh are relative to viewport height + self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportHeight) + elseif unit == "vw" then + -- vw is relative to viewport width + self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportWidth) + elseif unit == "ew" then + -- Element width relative + self.textSize = (value / 100) * self.width + elseif unit == "eh" then + -- Element height relative + self.textSize = (value / 100) * self.height + else + self.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, nil) + end + + -- Apply min/max constraints (with base scaling) + local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize) + local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) + + if minSize and self.textSize < minSize then + self.textSize = minSize + end + if maxSize and self.textSize > maxSize then + self.textSize = maxSize + end + + -- Protect against too-small text sizes (minimum 1px) + if self.textSize < 1 then + self.textSize = 1 -- Minimum 1px + end + elseif self.units.textSize.unit == "px" and self.units.textSize.value and Gui.baseScale then + -- No auto-scaling but base scaling is set: reapply base scaling to pixel text sizes + self.textSize = self.units.textSize.value * scaleY + + -- Protect against too-small text sizes (minimum 1px) + if self.textSize < 1 then + self.textSize = 1 -- Minimum 1px + end + end + + -- Final protection: ensure textSize is always at least 1px (catches all edge cases) + if self.text and self.textSize and self.textSize < 1 then + self.textSize = 1 -- Minimum 1px + end + + -- Recalculate gap if using viewport or percentage units + if self.units.gap.unit ~= "px" then + local containerSize = (self.flexDirection == FlexDirection.HORIZONTAL) and (self.parent and self.parent.width or newViewportWidth) + or (self.parent and self.parent.height or newViewportHeight) + self.gap = Units.resolve(self.units.gap.value, self.units.gap.unit, newViewportWidth, newViewportHeight, containerSize) + end + + -- Recalculate spacing (padding/margin) if using viewport or percentage units + -- For percentage-based padding: + -- - If element has a parent: use parent's border-box dimensions (CSS spec for child elements) + -- - If element has no parent: use element's own border-box dimensions (CSS spec for root elements) + local parentBorderBoxWidth = self.parent and self.parent._borderBoxWidth or self._borderBoxWidth or newViewportWidth + local parentBorderBoxHeight = self.parent and self.parent._borderBoxHeight or self._borderBoxHeight or newViewportHeight + + -- Handle shorthand properties first (horizontal/vertical) + local resolvedHorizontalPadding = nil + local resolvedVerticalPadding = nil + + if self.units.padding.horizontal and self.units.padding.horizontal.unit ~= "px" then + resolvedHorizontalPadding = + Units.resolve(self.units.padding.horizontal.value, self.units.padding.horizontal.unit, newViewportWidth, newViewportHeight, parentBorderBoxWidth) + elseif self.units.padding.horizontal and self.units.padding.horizontal.value then + resolvedHorizontalPadding = self.units.padding.horizontal.value + end + + if self.units.padding.vertical and self.units.padding.vertical.unit ~= "px" then + resolvedVerticalPadding = + Units.resolve(self.units.padding.vertical.value, self.units.padding.vertical.unit, newViewportWidth, newViewportHeight, parentBorderBoxHeight) + elseif self.units.padding.vertical and self.units.padding.vertical.value then + resolvedVerticalPadding = self.units.padding.vertical.value + end + + -- Resolve individual padding sides (with fallback to shorthand) + for _, side in ipairs({ "top", "right", "bottom", "left" }) do + -- Check if this side was explicitly set or if we should use shorthand + local useShorthand = false + if not self.units.padding[side].explicit then + -- Not explicitly set, check if we have shorthand + if side == "left" or side == "right" then + useShorthand = resolvedHorizontalPadding ~= nil + elseif side == "top" or side == "bottom" then + useShorthand = resolvedVerticalPadding ~= nil + end + end + + if useShorthand then + -- Use shorthand value + if side == "left" or side == "right" then + self.padding[side] = resolvedHorizontalPadding + else + self.padding[side] = resolvedVerticalPadding + end + elseif self.units.padding[side].unit ~= "px" then + -- Recalculate non-pixel units + local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth + self.padding[side] = Units.resolve(self.units.padding[side].value, self.units.padding[side].unit, newViewportWidth, newViewportHeight, parentSize) + end + -- If unit is "px" and not using shorthand, value stays the same + end + + -- Handle margin shorthand properties + local resolvedHorizontalMargin = nil + local resolvedVerticalMargin = nil + + if self.units.margin.horizontal and self.units.margin.horizontal.unit ~= "px" then + resolvedHorizontalMargin = + Units.resolve(self.units.margin.horizontal.value, self.units.margin.horizontal.unit, newViewportWidth, newViewportHeight, parentBorderBoxWidth) + elseif self.units.margin.horizontal and self.units.margin.horizontal.value then + resolvedHorizontalMargin = self.units.margin.horizontal.value + end + + if self.units.margin.vertical and self.units.margin.vertical.unit ~= "px" then + resolvedVerticalMargin = + Units.resolve(self.units.margin.vertical.value, self.units.margin.vertical.unit, newViewportWidth, newViewportHeight, parentBorderBoxHeight) + elseif self.units.margin.vertical and self.units.margin.vertical.value then + resolvedVerticalMargin = self.units.margin.vertical.value + end + + -- Resolve individual margin sides (with fallback to shorthand) + for _, side in ipairs({ "top", "right", "bottom", "left" }) do + -- Check if this side was explicitly set or if we should use shorthand + local useShorthand = false + if not self.units.margin[side].explicit then + -- Not explicitly set, check if we have shorthand + if side == "left" or side == "right" then + useShorthand = resolvedHorizontalMargin ~= nil + elseif side == "top" or side == "bottom" then + useShorthand = resolvedVerticalMargin ~= nil + end + end + + if useShorthand then + -- Use shorthand value + if side == "left" or side == "right" then + self.margin[side] = resolvedHorizontalMargin + else + self.margin[side] = resolvedVerticalMargin + end + elseif self.units.margin[side].unit ~= "px" then + -- Recalculate non-pixel units + local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth + self.margin[side] = Units.resolve(self.units.margin[side].value, self.units.margin[side].unit, newViewportWidth, newViewportHeight, parentSize) + end + -- If unit is "px" and not using shorthand, value stays the same + end + + -- BORDER-BOX MODEL: Calculate content dimensions from border-box dimensions + -- For explicitly-sized elements (non-auto), _borderBoxWidth/_borderBoxHeight were set earlier + -- Now we calculate content width/height by subtracting padding + -- Only recalculate if using viewport/percentage units (where _borderBoxWidth actually changed) + if self.units.width.unit ~= "auto" and self.units.width.unit ~= "px" then + -- _borderBoxWidth was recalculated for viewport/percentage units + -- Calculate content width by subtracting padding + self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) + elseif self.units.width.unit == "auto" then + -- For auto-sized elements, width is content width (calculated in resize method) + -- Update border-box to include padding + self._borderBoxWidth = self.width + self.padding.left + self.padding.right + end + -- For pixel units, width stays as-is (may have been manually modified) + + if self.units.height.unit ~= "auto" and self.units.height.unit ~= "px" then + -- _borderBoxHeight was recalculated for viewport/percentage units + -- Calculate content height by subtracting padding + self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) + elseif self.units.height.unit == "auto" then + -- For auto-sized elements, height is content height (calculated in resize method) + -- Update border-box to include padding + self._borderBoxHeight = self.height + self.padding.top + self.padding.bottom + end + -- For pixel units, height stays as-is (may have been manually modified) + + -- Detect overflow after layout calculations + self:_detectOverflow() +end + +--- Resize element and its children based on game window size change +---@param newGameWidth number +---@param newGameHeight number +function Element:resize(newGameWidth, newGameHeight) + self:recalculateUnits(newGameWidth, newGameHeight) + + -- For non-auto-sized elements with viewport/percentage units, update content dimensions from border-box + if not self.autosizing.width and self._borderBoxWidth and self.units.width.unit ~= "px" then + self.width = math.max(0, self._borderBoxWidth - self.padding.left - self.padding.right) + end + if not self.autosizing.height and self._borderBoxHeight and self.units.height.unit ~= "px" then + self.height = math.max(0, self._borderBoxHeight - self.padding.top - self.padding.bottom) + end + + -- Update children + for _, child in ipairs(self.children) do + child:resize(newGameWidth, newGameHeight) + end + + -- Recalculate auto-sized dimensions after children are resized + if self.autosizing.width then + local contentWidth = self:calculateAutoWidth() + -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content + self._borderBoxWidth = contentWidth + self.padding.left + self.padding.right + self.width = contentWidth + end + if self.autosizing.height then + local contentHeight = self:calculateAutoHeight() + -- BORDER-BOX MODEL: Add padding to get border-box, then subtract to get content + self._borderBoxHeight = contentHeight + self.padding.top + self.padding.bottom + self.height = contentHeight + end + + -- Re-resolve ew/eh textSize units after all dimensions are finalized + -- This ensures textSize updates based on current width/height (whether calculated or manually set) + if self.units.textSize.value then + local unit = self.units.textSize.unit + local value = self.units.textSize.value + local _, scaleY = Gui.getScaleFactors() + + if unit == "ew" then + -- Element width relative (use current width) + self.textSize = (value / 100) * self.width + + -- Apply min/max constraints + local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize) + local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) + if minSize and self.textSize < minSize then + self.textSize = minSize + end + if maxSize and self.textSize > maxSize then + self.textSize = maxSize + end + if self.textSize < 1 then + self.textSize = 1 + end + elseif unit == "eh" then + -- Element height relative (use current height) + self.textSize = (value / 100) * self.height + + -- Apply min/max constraints + local minSize = self.minTextSize and (Gui.baseScale and (self.minTextSize * scaleY) or self.minTextSize) + local maxSize = self.maxTextSize and (Gui.baseScale and (self.maxTextSize * scaleY) or self.maxTextSize) + if minSize and self.textSize < minSize then + self.textSize = minSize + end + if maxSize and self.textSize > maxSize then + self.textSize = maxSize + end + if self.textSize < 1 then + self.textSize = 1 + end + end + end + + self:layoutChildren() + self.prevGameSize.width = newGameWidth + self.prevGameSize.height = newGameHeight +end + +--- Calculate text width for button +---@return number +function Element:calculateTextWidth() + if self.text == nil then + return 0 + end + + if self.textSize then + -- Resolve font path from font family (same logic as in draw) + local fontPath = nil + if self.fontFamily then + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then + fontPath = themeToUse.fonts[self.fontFamily] + else + fontPath = self.fontFamily + end + elseif self.themeComponent then + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.fonts and themeToUse.fonts.default then + fontPath = themeToUse.fonts.default + end + end + + local tempFont = FONT_CACHE.get(self.textSize, fontPath) + local width = tempFont:getWidth(self.text) + -- Apply contentAutoSizingMultiplier if set + if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then + width = width * self.contentAutoSizingMultiplier.width + end + return width + end + + local font = love.graphics.getFont() + local width = font:getWidth(self.text) + -- Apply contentAutoSizingMultiplier if set + if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then + width = width * self.contentAutoSizingMultiplier.width + end + return width +end + +---@return number +function Element:calculateTextHeight() + if self.text == nil then + return 0 + end + + -- Get the font + local font + if self.textSize then + -- Resolve font path from font family (same logic as in draw) + local fontPath = nil + if self.fontFamily then + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then + fontPath = themeToUse.fonts[self.fontFamily] + else + fontPath = self.fontFamily + end + elseif self.themeComponent then + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.fonts and themeToUse.fonts.default then + fontPath = themeToUse.fonts.default + end + end + font = FONT_CACHE.get(self.textSize, fontPath) + else + font = love.graphics.getFont() + end + + local height = font:getHeight() + + -- If text wrapping is enabled, calculate height based on wrapped lines + if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then + -- Calculate available width for wrapping + local availableWidth = self.width + if availableWidth and availableWidth > 0 then + -- Get the wrapped text lines using getWrap (returns width and table of lines) + local wrappedWidth, wrappedLines = font:getWrap(self.text, availableWidth) + -- Height is line height * number of lines + height = height * #wrappedLines + end + end + + -- Apply contentAutoSizingMultiplier if set + if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.height then + height = height * self.contentAutoSizingMultiplier.height + end + + return height +end + +function Element:calculateAutoWidth() + -- BORDER-BOX MODEL: Calculate content width, caller will add padding to get border-box + local contentWidth = self:calculateTextWidth() + if not self.children or #self.children == 0 then + return contentWidth + end + + -- For HORIZONTAL flex: sum children widths + gaps + -- For VERTICAL flex: max of children widths + local isHorizontal = self.flexDirection == "horizontal" + local totalWidth = contentWidth + local maxWidth = contentWidth + local participatingChildren = 0 + + for _, child in ipairs(self.children) do + -- Skip explicitly absolute positioned children as they don't affect parent auto-sizing + if not child._explicitlyAbsolute then + -- BORDER-BOX MODEL: Use border-box width for auto-sizing calculations + local childBorderBoxWidth = child:getBorderBoxWidth() + if isHorizontal then + totalWidth = totalWidth + childBorderBoxWidth + else + maxWidth = math.max(maxWidth, childBorderBoxWidth) + end + participatingChildren = participatingChildren + 1 + end + end + + if isHorizontal then + -- Add gaps between children (n-1 gaps for n children) + local gapCount = math.max(0, participatingChildren - 1) + return totalWidth + (self.gap * gapCount) + else + return maxWidth + end +end + +--- Calculate auto height based on children +function Element:calculateAutoHeight() + local height = self:calculateTextHeight() + if not self.children or #self.children == 0 then + return height + end + + -- For VERTICAL flex: sum children heights + gaps + -- For HORIZONTAL flex: max of children heights + local isVertical = self.flexDirection == "vertical" + local totalHeight = height + local maxHeight = height + local participatingChildren = 0 + + for _, child in ipairs(self.children) do + -- Skip explicitly absolute positioned children as they don't affect parent auto-sizing + if not child._explicitlyAbsolute then + -- BORDER-BOX MODEL: Use border-box height for auto-sizing calculations + local childBorderBoxHeight = child:getBorderBoxHeight() + if isVertical then + totalHeight = totalHeight + childBorderBoxHeight + else + maxHeight = math.max(maxHeight, childBorderBoxHeight) + end + participatingChildren = participatingChildren + 1 + end + end + + if isVertical then + -- Add gaps between children (n-1 gaps for n children) + local gapCount = math.max(0, participatingChildren - 1) + return totalHeight + (self.gap * gapCount) + else + return maxHeight + end +end + +---@param newText string +---@param autoresize boolean? --default: false +function Element:updateText(newText, autoresize) + self.text = newText or self.text + if autoresize then + self.width = self:calculateTextWidth() + self.height = self:calculateTextHeight() + end +end + +---@param newOpacity number +function Element:updateOpacity(newOpacity) + self.opacity = newOpacity + for _, child in ipairs(self.children) do + child:updateOpacity(newOpacity) + end +end + +--- same as calling updateOpacity(0) +function Element:hide() + self:updateOpacity(0) +end + +--- same as calling updateOpacity(1) +function Element:show() + self:updateOpacity(1) +end + +-- ==================== +-- Input Handling - Cursor Management +-- ==================== + +--- Set cursor position +---@param position number -- Character index (0-based) +function Element:setCursorPosition(position) + if not self.editable then + return + end + self._cursorPosition = position + self:_validateCursorPosition() + self:_resetCursorBlink() +end + +--- Get cursor position +---@return number -- Character index (0-based) +function Element:getCursorPosition() + if not self.editable then + return 0 + end + return self._cursorPosition +end + +--- Move cursor by delta characters +---@param delta number -- Number of characters to move (positive or negative) +function Element:moveCursorBy(delta) + if not self.editable then + return + end + self._cursorPosition = self._cursorPosition + delta + self:_validateCursorPosition() + self:_resetCursorBlink() +end + +--- Move cursor to start of text +function Element:moveCursorToStart() + if not self.editable then + return + end + self._cursorPosition = 0 + self:_resetCursorBlink() +end + +--- Move cursor to end of text +function Element:moveCursorToEnd() + if not self.editable then + return + end + local textLength = utf8.len(self._textBuffer or "") + self._cursorPosition = textLength + self:_resetCursorBlink() +end + +--- Move cursor to start of current line +function Element:moveCursorToLineStart() + if not self.editable then + return + end + -- For now, just move to start (will be enhanced for multi-line) + self:moveCursorToStart() +end + +--- Move cursor to end of current line +function Element:moveCursorToLineEnd() + if not self.editable then + return + end + -- For now, just move to end (will be enhanced for multi-line) + self:moveCursorToEnd() +end + +--- Validate cursor position (ensure it's within text bounds) +function Element:_validateCursorPosition() + if not self.editable then + return + end + local textLength = utf8.len(self._textBuffer or "") + self._cursorPosition = math.max(0, math.min(self._cursorPosition, textLength)) +end + +--- Reset cursor blink (show cursor immediately) +function Element:_resetCursorBlink() + if not self.editable then + return + end + self._cursorBlinkTimer = 0 + self._cursorVisible = true +end + +-- ==================== +-- Input Handling - Selection Management +-- ==================== + +--- Set selection range +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +function Element:setSelection(startPos, endPos) + if not self.editable then + return + end + local textLength = utf8.len(self._textBuffer or "") + self._selectionStart = math.max(0, math.min(startPos, textLength)) + self._selectionEnd = math.max(0, math.min(endPos, textLength)) + + -- Ensure start <= end + if self._selectionStart > self._selectionEnd then + self._selectionStart, self._selectionEnd = self._selectionEnd, self._selectionStart + end + + self:_resetCursorBlink() +end + +--- Get selection range +---@return number?, number? -- Start and end positions, or nil if no selection +function Element:getSelection() + if not self.editable then + return nil, nil + end + if not self:hasSelection() then + return nil, nil + end + return self._selectionStart, self._selectionEnd +end + +--- Check if there is an active selection +---@return boolean +function Element:hasSelection() + if not self.editable then + return false + end + return self._selectionStart ~= nil and self._selectionEnd ~= nil and self._selectionStart ~= self._selectionEnd +end + +--- Clear selection +function Element:clearSelection() + if not self.editable then + return + end + self._selectionStart = nil + self._selectionEnd = nil + self._selectionAnchor = nil +end + +--- Select all text +function Element:selectAll() + if not self.editable then + return + end + local textLength = utf8.len(self._textBuffer or "") + self._selectionStart = 0 + self._selectionEnd = textLength + self:_resetCursorBlink() +end + +--- Get selected text +---@return string? -- Selected text or nil if no selection +function Element:getSelectedText() + if not self.editable or not self:hasSelection() then + return nil + end + local startPos, endPos = self:getSelection() + if not startPos or not endPos then + return nil + end + + -- Convert character indices to byte offsets for utf8.sub + local text = self._textBuffer or "" + return utf8.sub(text, startPos + 1, endPos) +end + +--- Delete selected text +---@return boolean -- True if text was deleted +function Element:deleteSelection() + if not self.editable or not self:hasSelection() then + return false + end + local startPos, endPos = self:getSelection() + if not startPos or not endPos then + return false + end + + self:deleteText(startPos, endPos) + self:clearSelection() + self._cursorPosition = startPos + self:_validateCursorPosition() + return true +end + +-- ==================== +-- Input Handling - Focus Management +-- ==================== + +--- Focus this element for keyboard input +function Element:focus() + if not self.editable then + return + end + + -- Blur previously focused element + if Gui._focusedElement and Gui._focusedElement ~= self then + Gui._focusedElement:blur() + end + + -- Set focus state + self._focused = true + Gui._focusedElement = self + + -- Reset cursor blink + self:_resetCursorBlink() + + -- Select all text if selectOnFocus is enabled + if self.selectOnFocus then + self:selectAll() + else + -- Move cursor to end of text + self:moveCursorToEnd() + end + + -- Trigger onFocus callback if defined + if self.onFocus then + self.onFocus(self) + end +end + +--- Remove focus from this element +function Element:blur() + if not self.editable then + return + end + + self._focused = false + + -- Clear global focused element if it's this element + if Gui._focusedElement == self then + Gui._focusedElement = nil + end + + -- Trigger onBlur callback if defined + if self.onBlur then + self.onBlur(self) + end +end + +--- Check if this element is focused +---@return boolean +function Element:isFocused() + if not self.editable then + return false + end + return self._focused == true +end + +-- ==================== +-- Input Handling - Text Buffer Management +-- ==================== + +--- Get current text buffer +---@return string +function Element:getText() + if not self.editable then + return self.text or "" + end + return self._textBuffer or "" +end + +--- Set text buffer and mark dirty +---@param text string +function Element:setText(text) + if not self.editable then + self.text = text + return + end + + self._textBuffer = text or "" + self.text = self._textBuffer -- Sync display text + self:_markTextDirty() + self:_validateCursorPosition() +end + +--- Insert text at position +---@param text string -- Text to insert +---@param position number? -- Position to insert at (default: cursor position) +function Element:insertText(text, position) + if not self.editable then + return + end + + position = position or self._cursorPosition + local buffer = self._textBuffer or "" + + -- Convert character position to byte offset + local byteOffset = utf8.offset(buffer, position + 1) or (#buffer + 1) + + -- Insert text + local before = buffer:sub(1, byteOffset - 1) + local after = buffer:sub(byteOffset) + self._textBuffer = before .. text .. after + self.text = self._textBuffer -- Sync display text + + -- Update cursor position + self._cursorPosition = position + utf8.len(text) + + self:_markTextDirty() + self:_validateCursorPosition() +end + +--- Delete text in range +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +function Element:deleteText(startPos, endPos) + if not self.editable then + return + end + + local buffer = self._textBuffer or "" + + -- Ensure valid range + local textLength = utf8.len(buffer) + startPos = math.max(0, math.min(startPos, textLength)) + endPos = math.max(0, math.min(endPos, textLength)) + + if startPos > endPos then + startPos, endPos = endPos, startPos + end + + -- Convert character positions to byte offsets + local startByte = utf8.offset(buffer, startPos + 1) or 1 + local endByte = utf8.offset(buffer, endPos + 1) or (#buffer + 1) + + -- Delete text + local before = buffer:sub(1, startByte - 1) + local after = buffer:sub(endByte) + self._textBuffer = before .. after + self.text = self._textBuffer -- Sync display text + + self:_markTextDirty() +end + +--- Replace text in range +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +---@param newText string -- Replacement text +function Element:replaceText(startPos, endPos, newText) + if not self.editable then + return + end + + self:deleteText(startPos, endPos) + self:insertText(newText, startPos) +end + +--- Mark text as dirty (needs recalculation) +function Element:_markTextDirty() + if not self.editable then + return + end + self._textDirty = true +end + +--- Update text if dirty (recalculate lines and wrapping) +function Element:_updateTextIfDirty() + if not self.editable or not self._textDirty then + return + end + + self:_splitLines() + self:_calculateWrapping() + self:_validateCursorPosition() + self._textDirty = false +end + +--- Split text into lines (for multi-line text) +function Element:_splitLines() + if not self.editable then + return + end + + if not self.multiline then + self._lines = { self._textBuffer or "" } + return + end + + self._lines = {} + local text = self._textBuffer or "" + + -- Split on newlines + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(self._lines, line) + end + + -- Ensure at least one line + if #self._lines == 0 then + self._lines = { "" } + end +end + +--- Calculate text wrapping +function Element:_calculateWrapping() + if not self.editable or not self.textWrap then + self._wrappedLines = nil + return + end + + self._wrappedLines = {} + local availableWidth = self.width - self.padding.left - self.padding.right + + for lineNum, line in ipairs(self._lines or {}) do + if line == "" then + table.insert(self._wrappedLines, { + text = "", + startIdx = 0, + endIdx = 0, + lineNum = lineNum, + }) + else + local wrappedParts = self:_wrapLine(line, availableWidth) + for _, part in ipairs(wrappedParts) do + part.lineNum = lineNum + table.insert(self._wrappedLines, part) + end + end + end +end + +--- Wrap a single line of text +---@param line string -- Line to wrap +---@param maxWidth number -- Maximum width in pixels +---@return table -- Array of wrapped line parts +function Element:_wrapLine(line, maxWidth) + if not self.editable then + return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } + end + + local font = self:_getFont() + local wrappedParts = {} + local currentLine = "" + local startIdx = 0 + + if self.textWrap == "word" then + -- Word wrapping + local words = {} + for word in line:gmatch("%S+") do + table.insert(words, word) + end + + for i, word in ipairs(words) do + local testLine = currentLine == "" and word or (currentLine .. " " .. word) + local width = font:getWidth(testLine) + + if width > maxWidth and currentLine ~= "" then + -- Current line is full, start new line + table.insert(wrappedParts, { + text = currentLine, + startIdx = startIdx, + endIdx = startIdx + utf8.len(currentLine), + }) + currentLine = word + startIdx = startIdx + utf8.len(currentLine) + 1 + else + currentLine = testLine + end + end + else + -- Character wrapping + local lineLength = utf8.len(line) + for i = 1, lineLength do + local char = utf8.sub(line, i, i) + local testLine = currentLine .. char + local width = font:getWidth(testLine) + + if width > maxWidth and currentLine ~= "" then + table.insert(wrappedParts, { + text = currentLine, + startIdx = startIdx, + endIdx = startIdx + utf8.len(currentLine), + }) + currentLine = char + startIdx = i - 1 + else + currentLine = testLine + end + end + end + + -- Add remaining text + if currentLine ~= "" then + table.insert(wrappedParts, { + text = currentLine, + startIdx = startIdx, + endIdx = startIdx + utf8.len(currentLine), + }) + end + + -- Ensure at least one part + if #wrappedParts == 0 then + table.insert(wrappedParts, { + text = "", + startIdx = 0, + endIdx = 0, + }) + end + + return wrappedParts +end + +--- Get font for text rendering +---@return love.Font +function Element:_getFont() + -- Get font path from theme or element + local fontPath = nil + if self.fontFamily then + local themeToUse = self.theme and themes[self.theme] or Theme.getActive() + if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then + fontPath = themeToUse.fonts[self.fontFamily] + else + -- Assume fontFamily is a direct path + fontPath = self.fontFamily + end + end + + return FONT_CACHE.getFont(self.textSize, fontPath) +end + +-- ==================== +-- Input Handling - Keyboard Input +-- ==================== + +--- Handle text input (character input) +---@param text string -- Character(s) to insert +function Element:textinput(text) + if not self.editable or not self._focused then + return + end + + -- Trigger onTextInput callback if defined + if self.onTextInput then + local result = self.onTextInput(self, text) + -- If callback returns false, cancel the input + if result == false then + return + end + end + + -- Capture old text for callback + local oldText = self._textBuffer + + -- Delete selection if exists + local hadSelection = self:hasSelection() + if hadSelection then + self:deleteSelection() + end + + -- Insert text at cursor position + self:insertText(text) + + -- Trigger onTextChange callback if text changed + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(self, self._textBuffer, oldText) + end +end + +--- Handle key press (special keys) +---@param key string -- Key name +---@param scancode string -- Scancode +---@param isrepeat boolean -- Whether this is a key repeat +function Element:keypressed(key, scancode, isrepeat) + if not self.editable or not self._focused then + return + end + + local modifiers = getModifiers() + local ctrl = modifiers.ctrl or modifiers.super -- Support both Ctrl and Cmd + + -- Handle cursor movement + if key == "left" then + if self:hasSelection() and not modifiers.shift then + -- Move to start of selection + local startPos, _ = self:getSelection() + self._cursorPosition = startPos + self:clearSelection() + else + self:moveCursorBy(-1) + end + self:_resetCursorBlink() + elseif key == "right" then + if self:hasSelection() and not modifiers.shift then + -- Move to end of selection + local _, endPos = self:getSelection() + self._cursorPosition = endPos + self:clearSelection() + else + self:moveCursorBy(1) + end + self:_resetCursorBlink() + elseif key == "home" or (ctrl and key == "a" and not self.multiline) then + -- Move to line start (or document start for single-line) + if ctrl or not self.multiline then + self:moveCursorToStart() + else + self:moveCursorToLineStart() + end + if key == "home" then + self:clearSelection() + end + self:_resetCursorBlink() + elseif key == "end" or (ctrl and key == "e" and not self.multiline) then + -- Move to line end (or document end for single-line) + if ctrl or not self.multiline then + self:moveCursorToEnd() + else + self:moveCursorToLineEnd() + end + if key == "end" then + self:clearSelection() + end + self:_resetCursorBlink() + + -- Handle backspace and delete + elseif key == "backspace" then + local oldText = self._textBuffer + if self:hasSelection() then + -- Delete selection + self:deleteSelection() + elseif self._cursorPosition > 0 then + -- Delete character before cursor + self:deleteText(self._cursorPosition - 1, self._cursorPosition) + self._cursorPosition = self._cursorPosition - 1 + self:_validateCursorPosition() + end + + -- Trigger onTextChange callback + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(self, self._textBuffer, oldText) + end + self:_resetCursorBlink() + elseif key == "delete" then + local oldText = self._textBuffer + if self:hasSelection() then + -- Delete selection + self:deleteSelection() + else + -- Delete character after cursor + local textLength = utf8.len(self._textBuffer or "") + if self._cursorPosition < textLength then + self:deleteText(self._cursorPosition, self._cursorPosition + 1) + end + end + + -- Trigger onTextChange callback + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(self, self._textBuffer, oldText) + end + self:_resetCursorBlink() + + -- Handle return/enter + elseif key == "return" or key == "kpenter" then + if self.multiline then + -- Insert newline + local oldText = self._textBuffer + if self:hasSelection() then + self:deleteSelection() + end + self:insertText("\n") + + -- Trigger onTextChange callback + if self.onTextChange and self._textBuffer ~= oldText then + self.onTextChange(self, self._textBuffer, oldText) + end + else + -- Trigger onEnter callback for single-line + if self.onEnter then + self.onEnter(self) + end + end + self:_resetCursorBlink() + + -- Handle Ctrl/Cmd+A (select all) + elseif ctrl and key == "a" then + self:selectAll() + self:_resetCursorBlink() + + -- Handle Escape + elseif key == "escape" then + if self:hasSelection() then + -- Clear selection + self:clearSelection() + else + -- Blur element + self:blur() + end + self:_resetCursorBlink() + end +end + +return Element diff --git a/flexlove/ImageCache.lua b/flexlove/ImageCache.lua new file mode 100644 index 0000000..b7779be --- /dev/null +++ b/flexlove/ImageCache.lua @@ -0,0 +1,157 @@ +--[[ +ImageCache.lua - Image caching system for FlexLove +Provides efficient image loading and caching with memory management +]] + +-- ==================== +-- ImageCache +-- ==================== + +---@class ImageCache +---@field _cache table +local ImageCache = {} +ImageCache._cache = {} + +--- Normalize a file path for consistent cache keys +---@param path string -- File path to normalize +---@return string -- Normalized path +local function normalizePath(path) + -- Remove leading/trailing whitespace + path = path:match("^%s*(.-)%s*$") + -- Convert backslashes to forward slashes + path = path:gsub("\\", "/") + -- Remove redundant slashes + path = path:gsub("/+", "/") + return path +end + +--- Load an image from file path with caching +--- Returns cached image if already loaded, otherwise loads and caches it +---@param imagePath string -- Path to image file +---@param loadImageData boolean? -- Optional: also load ImageData for pixel access (default: false) +---@return love.Image|nil -- Image object or nil on error +---@return string|nil -- Error message if loading failed +function ImageCache.load(imagePath, loadImageData) + if not imagePath or type(imagePath) ~= "string" or imagePath == "" then + return nil, "Invalid image path: path must be a non-empty string" + end + + local normalizedPath = normalizePath(imagePath) + + -- Check if already cached + if ImageCache._cache[normalizedPath] then + return ImageCache._cache[normalizedPath].image, nil + end + + -- Try to load the image + local success, imageOrError = pcall(love.graphics.newImage, normalizedPath) + if not success then + return nil, string.format("Failed to load image '%s': %s", imagePath, tostring(imageOrError)) + end + + local image = imageOrError + local imgData = nil + + -- Load ImageData if requested + if loadImageData then + local dataSuccess, dataOrError = pcall(love.image.newImageData, normalizedPath) + if dataSuccess then + imgData = dataOrError + end + end + + -- Cache the image + ImageCache._cache[normalizedPath] = { + image = image, + imageData = imgData, + } + + return image, nil +end + +--- Get a cached image without loading +---@param imagePath string -- Path to image file +---@return love.Image|nil -- Cached image or nil if not found +function ImageCache.get(imagePath) + if not imagePath or type(imagePath) ~= "string" then + return nil + end + + local normalizedPath = normalizePath(imagePath) + local cached = ImageCache._cache[normalizedPath] + return cached and cached.image or nil +end + +--- Get cached ImageData for an image +---@param imagePath string -- Path to image file +---@return love.ImageData|nil -- Cached ImageData or nil if not found +function ImageCache.getImageData(imagePath) + if not imagePath or type(imagePath) ~= "string" then + return nil + end + + local normalizedPath = normalizePath(imagePath) + local cached = ImageCache._cache[normalizedPath] + return cached and cached.imageData or nil +end + +--- Remove a specific image from cache +---@param imagePath string -- Path to image file to remove +---@return boolean -- True if image was removed, false if not found +function ImageCache.remove(imagePath) + if not imagePath or type(imagePath) ~= "string" then + return false + end + + local normalizedPath = normalizePath(imagePath) + if ImageCache._cache[normalizedPath] then + -- Release the image + local cached = ImageCache._cache[normalizedPath] + if cached.image then + cached.image:release() + end + if cached.imageData then + cached.imageData:release() + end + ImageCache._cache[normalizedPath] = nil + return true + end + return false +end + +--- Clear all cached images +function ImageCache.clear() + -- Release all images + for path, cached in pairs(ImageCache._cache) do + if cached.image then + cached.image:release() + end + if cached.imageData then + cached.imageData:release() + end + end + ImageCache._cache = {} +end + +--- Get cache statistics +---@return {count: number, memoryEstimate: number} -- Cache stats +function ImageCache.getStats() + local count = 0 + local memoryEstimate = 0 + + for path, cached in pairs(ImageCache._cache) do + count = count + 1 + if cached.image then + local w, h = cached.image:getDimensions() + -- Estimate: 4 bytes per pixel (RGBA) + memoryEstimate = memoryEstimate + (w * h * 4) + end + end + + return { + count = count, + memoryEstimate = memoryEstimate, + } +end + +return ImageCache diff --git a/flexlove/ImageDataReader.lua b/flexlove/ImageDataReader.lua new file mode 100644 index 0000000..8f9dff6 --- /dev/null +++ b/flexlove/ImageDataReader.lua @@ -0,0 +1,113 @@ +--[[ +ImageDataReader.lua - Image data reading utilities for FlexLove +Provides functions to load and read pixel data from images +]] + +-- ==================== +-- Error Handling Utilities +-- ==================== + +--- Standardized error message formatter +---@param module string -- Module name (e.g., "Color", "Theme", "Units") +---@param message string -- Error message +---@return string -- Formatted error message +local function formatError(module, message) + return string.format("[FlexLove.%s] %s", module, message) +end + +-- ==================== +-- ImageDataReader +-- ==================== + +local ImageDataReader = {} + +--- Load ImageData from a file path +---@param imagePath string +---@return love.ImageData +function ImageDataReader.loadImageData(imagePath) + if not imagePath then + error(formatError("ImageDataReader", "Image path cannot be nil")) + end + + local success, result = pcall(function() + return love.image.newImageData(imagePath) + end) + + if not success then + error(formatError("ImageDataReader", "Failed to load image data from '" .. imagePath .. "': " .. tostring(result))) + end + + return result +end + +--- Extract all pixels from a specific row +---@param imageData love.ImageData +---@param rowIndex number -- 0-based row index +---@return table -- Array of {r, g, b, a} values (0-255 range) +function ImageDataReader.getRow(imageData, rowIndex) + if not imageData then + error(formatError("ImageDataReader", "ImageData cannot be nil")) + end + + local width = imageData:getWidth() + local height = imageData:getHeight() + + if rowIndex < 0 or rowIndex >= height then + error(formatError("ImageDataReader", string.format("Row index %d out of bounds (height: %d)", rowIndex, height))) + end + + local pixels = {} + for x = 0, width - 1 do + local r, g, b, a = imageData:getPixel(x, rowIndex) + table.insert(pixels, { + r = math.floor(r * 255 + 0.5), + g = math.floor(g * 255 + 0.5), + b = math.floor(b * 255 + 0.5), + a = math.floor(a * 255 + 0.5), + }) + end + + return pixels +end + +--- Extract all pixels from a specific column +---@param imageData love.ImageData +---@param colIndex number -- 0-based column index +---@return table -- Array of {r, g, b, a} values (0-255 range) +function ImageDataReader.getColumn(imageData, colIndex) + if not imageData then + error(formatError("ImageDataReader", "ImageData cannot be nil")) + end + + local width = imageData:getWidth() + local height = imageData:getHeight() + + if colIndex < 0 or colIndex >= width then + error(formatError("ImageDataReader", string.format("Column index %d out of bounds (width: %d)", colIndex, width))) + end + + local pixels = {} + for y = 0, height - 1 do + local r, g, b, a = imageData:getPixel(colIndex, y) + table.insert(pixels, { + r = math.floor(r * 255 + 0.5), + g = math.floor(g * 255 + 0.5), + b = math.floor(b * 255 + 0.5), + a = math.floor(a * 255 + 0.5), + }) + end + + return pixels +end + +--- Check if a pixel is black with full alpha (9-patch marker) +---@param r number -- Red (0-255) +---@param g number -- Green (0-255) +---@param b number -- Blue (0-255) +---@param a number -- Alpha (0-255) +---@return boolean +function ImageDataReader.isBlackPixel(r, g, b, a) + return r == 0 and g == 0 and b == 0 and a == 255 +end + +return ImageDataReader diff --git a/flexlove/ImageRenderer.lua b/flexlove/ImageRenderer.lua new file mode 100644 index 0000000..b5e7871 --- /dev/null +++ b/flexlove/ImageRenderer.lua @@ -0,0 +1,234 @@ +--[[ +ImageRenderer.lua - Image rendering utilities for FlexLove +Provides object-fit modes and object-position support for image rendering +]] + +-- ==================== +-- Error Handling Utilities +-- ==================== + +--- Standardized error message formatter +---@param module string -- Module name (e.g., "Color", "Theme", "Units") +---@param message string -- Error message +---@return string -- Formatted error message +local function formatError(module, message) + return string.format("[FlexLove.%s] %s", module, message) +end + +-- ==================== +-- ImageRenderer +-- ==================== + +---@class ImageRenderer +local ImageRenderer = {} + +--- Calculate rendering parameters for object-fit modes +--- Returns source and destination rectangles for rendering +---@param imageWidth number -- Natural width of the image +---@param imageHeight number -- Natural height of the image +---@param boundsWidth number -- Width of the bounds to fit within +---@param boundsHeight number -- Height of the bounds to fit within +---@param fitMode string? -- One of: "fill", "contain", "cover", "scale-down", "none" (default: "fill") +---@param objectPosition string? -- Position like "center center", "top left", "50% 50%" (default: "center center") +---@return {sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number, scaleX: number, scaleY: number} +function ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, fitMode, objectPosition) + fitMode = fitMode or "fill" + objectPosition = objectPosition or "center center" + + -- Validate inputs + if imageWidth <= 0 or imageHeight <= 0 or boundsWidth <= 0 or boundsHeight <= 0 then + error(formatError("ImageRenderer", "Dimensions must be positive")) + end + + local result = { + sx = 0, -- Source X + sy = 0, -- Source Y + sw = imageWidth, -- Source width + sh = imageHeight, -- Source height + dx = 0, -- Destination X + dy = 0, -- Destination Y + dw = boundsWidth, -- Destination width + dh = boundsHeight, -- Destination height + scaleX = 1, -- Scale factor X + scaleY = 1, -- Scale factor Y + } + + -- Calculate based on fit mode + if fitMode == "fill" then + -- Stretch to fill bounds (may distort) + result.scaleX = boundsWidth / imageWidth + result.scaleY = boundsHeight / imageHeight + result.dw = boundsWidth + result.dh = boundsHeight + elseif fitMode == "contain" then + -- Scale to fit within bounds (preserves aspect ratio) + local scale = math.min(boundsWidth / imageWidth, boundsHeight / imageHeight) + result.scaleX = scale + result.scaleY = scale + result.dw = imageWidth * scale + result.dh = imageHeight * scale + + -- Apply object-position for letterbox alignment + local posX, posY = ImageRenderer._parsePosition(objectPosition) + result.dx = (boundsWidth - result.dw) * posX + result.dy = (boundsHeight - result.dh) * posY + elseif fitMode == "cover" then + -- Scale to cover bounds (preserves aspect ratio, may crop) + local scale = math.max(boundsWidth / imageWidth, boundsHeight / imageHeight) + result.scaleX = scale + result.scaleY = scale + + local scaledWidth = imageWidth * scale + local scaledHeight = imageHeight * scale + + -- Apply object-position for crop alignment + local posX, posY = ImageRenderer._parsePosition(objectPosition) + + -- Calculate which part of the scaled image to show + local cropX = (scaledWidth - boundsWidth) * posX + local cropY = (scaledHeight - boundsHeight) * posY + + -- Convert back to source coordinates + result.sx = cropX / scale + result.sy = cropY / scale + result.sw = boundsWidth / scale + result.sh = boundsHeight / scale + + result.dx = 0 + result.dy = 0 + result.dw = boundsWidth + result.dh = boundsHeight + elseif fitMode == "none" then + -- Use natural size (no scaling) + result.scaleX = 1 + result.scaleY = 1 + result.dw = imageWidth + result.dh = imageHeight + + -- Apply object-position + local posX, posY = ImageRenderer._parsePosition(objectPosition) + result.dx = (boundsWidth - imageWidth) * posX + result.dy = (boundsHeight - imageHeight) * posY + elseif fitMode == "scale-down" then + -- Use none or contain, whichever is smaller + if imageWidth <= boundsWidth and imageHeight <= boundsHeight then + -- Image fits naturally, use "none" + return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "none", objectPosition) + else + -- Image too large, use "contain" + return ImageRenderer.calculateFit(imageWidth, imageHeight, boundsWidth, boundsHeight, "contain", objectPosition) + end + else + error(formatError("ImageRenderer", string.format("Invalid fit mode: '%s'. Must be one of: fill, contain, cover, scale-down, none", tostring(fitMode)))) + end + + return result +end + +--- Parse object-position string into normalized coordinates (0-1) +--- Supports keywords (center, top, bottom, left, right) and percentages +---@param position string -- Position string like "center center", "top left", "50% 50%" +---@return number, number -- Normalized X and Y positions (0-1) +function ImageRenderer._parsePosition(position) + if not position or type(position) ~= "string" then + return 0.5, 0.5 -- Default to center + end + + -- Split into X and Y components + local parts = {} + for part in position:gmatch("%S+") do + table.insert(parts, part:lower()) + end + + -- If only one value, use it for both axes (with special handling) + if #parts == 1 then + local val = parts[1] + if val == "left" or val == "right" then + parts = { val, "center" } + elseif val == "top" or val == "bottom" then + parts = { "center", val } + else + parts = { val, val } + end + elseif #parts == 0 then + return 0.5, 0.5 -- Default to center + end + + local function parseValue(val) + -- Handle keywords + if val == "center" then + return 0.5 + elseif val == "left" or val == "top" then + return 0 + elseif val == "right" or val == "bottom" then + return 1 + end + + -- Handle percentages + local percent = val:match("^([%d%.]+)%%$") + if percent then + return tonumber(percent) / 100 + end + + -- Handle plain numbers (treat as percentage) + local num = tonumber(val) + if num then + return num / 100 + end + + -- Invalid value, default to center + return 0.5 + end + + local x = parseValue(parts[1]) + local y = parseValue(parts[2] or parts[1]) + + -- Clamp to 0-1 range + x = math.max(0, math.min(1, x)) + y = math.max(0, math.min(1, y)) + + return x, y +end + +--- Draw an image with specified object-fit 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 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) + if not image then + return -- Nothing to draw + end + + opacity = opacity or 1 + fitMode = fitMode or "fill" + objectPosition = objectPosition or "center center" + + local imgWidth, imgHeight = image:getDimensions() + local params = ImageRenderer.calculateFit(imgWidth, imgHeight, width, height, fitMode, objectPosition) + + -- Save current color + local r, g, b, a = love.graphics.getColor() + + -- Apply opacity + love.graphics.setColor(1, 1, 1, opacity) + + -- Draw image + if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then + -- Need to use a quad for cropping + local quad = love.graphics.newQuad(params.sx, params.sy, params.sw, params.sh, imgWidth, imgHeight) + love.graphics.draw(image, quad, x + params.dx, y + params.dy, 0, params.dw / params.sw, params.dh / params.sh) + else + -- Simple draw with scaling + love.graphics.draw(image, x + params.dx, y + params.dy, 0, params.scaleX, params.scaleY) + end + + -- Restore color + love.graphics.setColor(r, g, b, a) +end + +return ImageRenderer diff --git a/flexlove/ImageScaler.lua b/flexlove/ImageScaler.lua new file mode 100644 index 0000000..c71d470 --- /dev/null +++ b/flexlove/ImageScaler.lua @@ -0,0 +1,156 @@ +--[[ +ImageScaler.lua - Image scaling utilities for FlexLove +Provides nearest-neighbor and bilinear interpolation scaling algorithms +]] + +-- ==================== +-- Error Handling Utilities +-- ==================== + +--- Standardized error message formatter +---@param module string -- Module name (e.g., "Color", "Theme", "Units") +---@param message string -- Error message +---@return string -- Formatted error message +local function formatError(module, message) + return string.format("[FlexLove.%s] %s", module, message) +end + +-- ==================== +-- ImageScaler +-- ==================== + +local ImageScaler = {} + +--- Scale an ImageData region using nearest-neighbor sampling +--- Produces sharp, pixelated scaling - ideal for pixel art +---@param sourceImageData love.ImageData -- Source image data +---@param srcX number -- Source region X (0-based) +---@param srcY number -- Source region Y (0-based) +---@param srcW number -- Source region width +---@param srcH number -- Source region height +---@param destW number -- Destination width +---@param destH number -- Destination height +---@return love.ImageData -- Scaled image data +function ImageScaler.scaleNearest(sourceImageData, srcX, srcY, srcW, srcH, destW, destH) + if not sourceImageData then + error(formatError("ImageScaler", "Source ImageData cannot be nil")) + end + + if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then + error(formatError("ImageScaler", "Dimensions must be positive")) + end + + -- Create destination ImageData + local destImageData = love.image.newImageData(destW, destH) + + -- Calculate scale ratios (cached outside loops for performance) + local scaleX = srcW / destW + local scaleY = srcH / destH + + -- Nearest-neighbor sampling + for destY = 0, destH - 1 do + for destX = 0, destW - 1 do + -- Calculate source pixel coordinates using floor (nearest-neighbor) + local srcPixelX = math.floor(destX * scaleX) + srcX + local srcPixelY = math.floor(destY * scaleY) + srcY + + -- Clamp to source bounds (safety check) + srcPixelX = math.min(srcPixelX, srcX + srcW - 1) + srcPixelY = math.min(srcPixelY, srcY + srcH - 1) + + -- Sample source pixel + local r, g, b, a = sourceImageData:getPixel(srcPixelX, srcPixelY) + + -- Write to destination + destImageData:setPixel(destX, destY, r, g, b, a) + end + end + + return destImageData +end + +--- Linear interpolation helper +--- Blends between two values based on interpolation factor +---@param a number -- Start value +---@param b number -- End value +---@param t number -- Interpolation factor [0, 1] +---@return number -- Interpolated value +local function lerp(a, b, t) + return a + (b - a) * t +end + +--- Scale an ImageData region using bilinear interpolation +--- Produces smooth, filtered scaling - ideal for high-quality upscaling +---@param sourceImageData love.ImageData -- Source image data +---@param srcX number -- Source region X (0-based) +---@param srcY number -- Source region Y (0-based) +---@param srcW number -- Source region width +---@param srcH number -- Source region height +---@param destW number -- Destination width +---@param destH number -- Destination height +---@return love.ImageData -- Scaled image data +function ImageScaler.scaleBilinear(sourceImageData, srcX, srcY, srcW, srcH, destW, destH) + if not sourceImageData then + error(formatError("ImageScaler", "Source ImageData cannot be nil")) + end + + if srcW <= 0 or srcH <= 0 or destW <= 0 or destH <= 0 then + error(formatError("ImageScaler", "Dimensions must be positive")) + end + + -- Create destination ImageData + local destImageData = love.image.newImageData(destW, destH) + + -- Calculate scale ratios + local scaleX = srcW / destW + local scaleY = srcH / destH + + -- Bilinear interpolation + for destY = 0, destH - 1 do + for destX = 0, destW - 1 do + -- Calculate fractional source position + local srcXf = destX * scaleX + local srcYf = destY * scaleY + + -- Get integer coordinates for 2x2 sampling grid + local x0 = math.floor(srcXf) + local y0 = math.floor(srcYf) + local x1 = math.min(x0 + 1, srcW - 1) + local y1 = math.min(y0 + 1, srcH - 1) + + -- Get fractional parts for interpolation + local fx = srcXf - x0 + local fy = srcYf - y0 + + -- Sample 4 neighboring pixels (with source offset) + local r00, g00, b00, a00 = sourceImageData:getPixel(srcX + x0, srcY + y0) + local r10, g10, b10, a10 = sourceImageData:getPixel(srcX + x1, srcY + y0) + local r01, g01, b01, a01 = sourceImageData:getPixel(srcX + x0, srcY + y1) + local r11, g11, b11, a11 = sourceImageData:getPixel(srcX + x1, srcY + y1) + + -- Interpolate horizontally (top and bottom rows) + local rTop = lerp(r00, r10, fx) + local gTop = lerp(g00, g10, fx) + local bTop = lerp(b00, b10, fx) + local aTop = lerp(a00, a10, fx) + + local rBottom = lerp(r01, r11, fx) + local gBottom = lerp(g01, g11, fx) + local bBottom = lerp(b01, b11, fx) + local aBottom = lerp(a01, a11, fx) + + -- Interpolate vertically (final result) + local r = lerp(rTop, rBottom, fy) + local g = lerp(gTop, gBottom, fy) + local b = lerp(bTop, bBottom, fy) + local a = lerp(aTop, aBottom, fy) + + -- Write to destination + destImageData:setPixel(destX, destY, r, g, b, a) + end + end + + return destImageData +end + +return ImageScaler diff --git a/flexlove/NinePatchParser.lua b/flexlove/NinePatchParser.lua new file mode 100644 index 0000000..e01d032 --- /dev/null +++ b/flexlove/NinePatchParser.lua @@ -0,0 +1,177 @@ +--[[ +NinePatchParser.lua - 9-patch PNG parser for FlexLove +Parses Android-style 9-patch images to extract stretch regions and content padding +]] + +-- ==================== +-- Error Handling Utilities +-- ==================== + +--- Standardized error message formatter +---@param module string -- Module name (e.g., "Color", "Theme", "Units") +---@param message string -- Error message +---@return string -- Formatted error message +local function formatError(module, message) + return string.format("[FlexLove.%s] %s", module, message) +end + +-- ==================== +-- Dependencies +-- ==================== + +local ImageDataReader = require((...):match("(.-)[^%.]+$") .. "ImageDataReader") + +-- ==================== +-- NinePatchParser +-- ==================== + +local NinePatchParser = {} + +--- Find all continuous runs of black pixels in a pixel array +---@param pixels table -- Array of {r, g, b, a} pixel values +---@return table -- Array of {start, end} pairs (1-based indices, inclusive) +local function findBlackPixelRuns(pixels) + local runs = {} + local inRun = false + local runStart = nil + + for i = 1, #pixels do + local pixel = pixels[i] + local isBlack = ImageDataReader.isBlackPixel(pixel.r, pixel.g, pixel.b, pixel.a) + + if isBlack and not inRun then + -- Start of a new run + inRun = true + runStart = i + elseif not isBlack and inRun then + -- End of current run + table.insert(runs, { start = runStart, ["end"] = i - 1 }) + inRun = false + runStart = nil + end + end + + -- Handle case where run extends to end of array + if inRun then + table.insert(runs, { start = runStart, ["end"] = #pixels }) + end + + return runs +end + +--- Parse a 9-patch PNG image to extract stretch regions and content padding +---@param imagePath string -- Path to the 9-patch image file +---@return table|nil, string|nil -- Returns {insets, stretchX, stretchY} or nil, error message +function NinePatchParser.parse(imagePath) + if not imagePath then + return nil, "Image path cannot be nil" + end + + local success, imageData = pcall(function() + return ImageDataReader.loadImageData(imagePath) + end) + + if not success then + return nil, "Failed to load image data: " .. tostring(imageData) + end + + local width = imageData:getWidth() + local height = imageData:getHeight() + + -- Validate minimum size (must be at least 3x3 with 1px border) + if width < 3 or height < 3 then + return nil, string.format("Invalid 9-patch dimensions: %dx%d (minimum 3x3)", width, height) + end + + -- Extract border pixels (0-based indexing, but we convert to 1-based for processing) + local topBorder = ImageDataReader.getRow(imageData, 0) + local leftBorder = ImageDataReader.getColumn(imageData, 0) + local bottomBorder = ImageDataReader.getRow(imageData, height - 1) + local rightBorder = ImageDataReader.getColumn(imageData, width - 1) + + -- Remove corner pixels from borders (they're not part of the stretch/content markers) + -- Top and bottom borders: remove first and last pixel + local topStretchPixels = {} + local bottomContentPixels = {} + for i = 2, #topBorder - 1 do + table.insert(topStretchPixels, topBorder[i]) + end + for i = 2, #bottomBorder - 1 do + table.insert(bottomContentPixels, bottomBorder[i]) + end + + -- Left and right borders: remove first and last pixel + local leftStretchPixels = {} + local rightContentPixels = {} + for i = 2, #leftBorder - 1 do + table.insert(leftStretchPixels, leftBorder[i]) + end + for i = 2, #rightBorder - 1 do + table.insert(rightContentPixels, rightBorder[i]) + end + + -- Find stretch regions (top and left borders) + local stretchX = findBlackPixelRuns(topStretchPixels) + local stretchY = findBlackPixelRuns(leftStretchPixels) + + -- Find content padding regions (bottom and right borders) + local contentX = findBlackPixelRuns(bottomContentPixels) + local contentY = findBlackPixelRuns(rightContentPixels) + + -- Validate that we have at least one stretch region + if #stretchX == 0 or #stretchY == 0 then + return nil, "No stretch regions found (top or left border has no black pixels)" + end + + -- Calculate stretch insets from stretch regions (top/left guides) + -- Use the first stretch region's start and last stretch region's end + local firstStretchX = stretchX[1] + local lastStretchX = stretchX[#stretchX] + local firstStretchY = stretchY[1] + local lastStretchY = stretchY[#stretchY] + + -- Stretch insets define the 9-slice regions + local stretchLeft = firstStretchX.start + local stretchRight = #topStretchPixels - lastStretchX["end"] + local stretchTop = firstStretchY.start + local stretchBottom = #leftStretchPixels - lastStretchY["end"] + + -- Calculate content padding from content guides (bottom/right guides) + -- If content padding is defined, use it; otherwise use stretch regions + local contentLeft, contentRight, contentTop, contentBottom + + if #contentX > 0 then + contentLeft = contentX[1].start + contentRight = #topStretchPixels - contentX[#contentX]["end"] + else + contentLeft = stretchLeft + contentRight = stretchRight + end + + if #contentY > 0 then + contentTop = contentY[1].start + contentBottom = #leftStretchPixels - contentY[#contentY]["end"] + else + contentTop = stretchTop + contentBottom = stretchBottom + end + + return { + insets = { + left = stretchLeft, + top = stretchTop, + right = stretchRight, + bottom = stretchBottom, + }, + contentPadding = { + left = contentLeft, + top = contentTop, + right = contentRight, + bottom = contentBottom, + }, + stretchX = stretchX, + stretchY = stretchY, + } +end + +return NinePatchParser diff --git a/flexlove/NineSlice.lua b/flexlove/NineSlice.lua new file mode 100644 index 0000000..b288494 --- /dev/null +++ b/flexlove/NineSlice.lua @@ -0,0 +1,197 @@ +--[[ +NineSlice - 9-Patch Renderer for FlexLove +Handles rendering of 9-patch components with Android-style scaling. +Corners can be scaled independently while edges stretch in one dimension. +]] + +local ImageScaler = require("flexlove.ImageScaler") + +--- Standardized error message formatter +---@param module string -- Module name (e.g., "Color", "Theme", "Units") +---@param message string -- Error message +---@return string -- Formatted error message +local function formatError(module, message) + return string.format("[FlexLove.%s] %s", module, message) +end + +local NineSlice = {} + +--- Draw a 9-patch component using Android-style rendering +--- Corners are scaled by scaleCorners multiplier, edges stretch in one dimension only +---@param component ThemeComponent +---@param atlas love.Image +---@param x number -- X position (top-left corner) +---@param y number -- Y position (top-left corner) +---@param width number -- Total width (border-box) +---@param height number -- Total height (border-box) +---@param opacity number? +---@param elementScaleCorners number? -- Element-level override for scaleCorners (scale multiplier) +---@param elementScalingAlgorithm "nearest"|"bilinear"? -- Element-level override for scalingAlgorithm +function NineSlice.draw(component, atlas, x, y, width, height, opacity, elementScaleCorners, elementScalingAlgorithm) + if not component or not atlas then + return + end + + opacity = opacity or 1 + love.graphics.setColor(1, 1, 1, opacity) + + local regions = component.regions + + -- Extract border dimensions from regions (in pixels) + local left = regions.topLeft.w + local right = regions.topRight.w + local top = regions.topLeft.h + local bottom = regions.bottomLeft.h + local centerW = regions.middleCenter.w + local centerH = regions.middleCenter.h + + -- Calculate content area (space remaining after borders) + local contentWidth = width - left - right + local contentHeight = height - top - bottom + + -- Clamp to prevent negative dimensions + contentWidth = math.max(0, contentWidth) + contentHeight = math.max(0, contentHeight) + + -- Calculate stretch scales for edges and center + local scaleX = contentWidth / centerW + local scaleY = contentHeight / centerH + + -- Create quads for each region + local atlasWidth, atlasHeight = atlas:getDimensions() + + local function makeQuad(region) + return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight) + end + + -- Get corner scale multiplier + -- Priority: element-level override > component setting > default (nil = no scaling) + local scaleCorners = elementScaleCorners + if scaleCorners == nil then + scaleCorners = component.scaleCorners + end + + -- Priority: element-level override > component setting > default ("bilinear") + local scalingAlgorithm = elementScalingAlgorithm + if scalingAlgorithm == nil then + scalingAlgorithm = component.scalingAlgorithm or "bilinear" + end + + if scaleCorners and type(scaleCorners) == "number" and scaleCorners > 0 then + -- Initialize cache if needed + if not component._scaledRegionCache then + component._scaledRegionCache = {} + end + + -- Use the numeric scale multiplier directly + local scaleFactor = scaleCorners + + -- Helper to get or create scaled region + local function getScaledRegion(regionName, region, targetWidth, targetHeight) + local cacheKey = string.format("%s_%.2f_%s", regionName, scaleFactor, scalingAlgorithm) + + if component._scaledRegionCache[cacheKey] then + return component._scaledRegionCache[cacheKey] + end + + -- Get ImageData from component (stored during theme loading) + local atlasData = component._loadedAtlasData + if not atlasData then + error(formatError("NineSlice", "No ImageData available for atlas. Image must be loaded with safeLoadImage.")) + end + + local scaledData + + if scalingAlgorithm == "nearest" then + scaledData = ImageScaler.scaleNearest(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight) + else + scaledData = ImageScaler.scaleBilinear(atlasData, region.x, region.y, region.w, region.h, targetWidth, targetHeight) + end + + -- Convert to image and cache + local scaledImage = love.graphics.newImage(scaledData) + component._scaledRegionCache[cacheKey] = scaledImage + + return scaledImage + end + + -- Calculate scaled dimensions for corners + local scaledLeft = math.floor(left * scaleFactor + 0.5) + local scaledRight = math.floor(right * scaleFactor + 0.5) + local scaledTop = math.floor(top * scaleFactor + 0.5) + local scaledBottom = math.floor(bottom * scaleFactor + 0.5) + + -- CORNERS (scaled using algorithm) + local topLeftScaled = getScaledRegion("topLeft", regions.topLeft, scaledLeft, scaledTop) + local topRightScaled = getScaledRegion("topRight", regions.topRight, scaledRight, scaledTop) + local bottomLeftScaled = getScaledRegion("bottomLeft", regions.bottomLeft, scaledLeft, scaledBottom) + local bottomRightScaled = getScaledRegion("bottomRight", regions.bottomRight, scaledRight, scaledBottom) + + love.graphics.draw(topLeftScaled, x, y) + love.graphics.draw(topRightScaled, x + width - scaledRight, y) + love.graphics.draw(bottomLeftScaled, x, y + height - scaledBottom) + love.graphics.draw(bottomRightScaled, x + width - scaledRight, y + height - scaledBottom) + + -- Update content dimensions to account for scaled borders + local adjustedContentWidth = width - scaledLeft - scaledRight + local adjustedContentHeight = height - scaledTop - scaledBottom + adjustedContentWidth = math.max(0, adjustedContentWidth) + adjustedContentHeight = math.max(0, adjustedContentHeight) + + -- Recalculate stretch scales + local adjustedScaleX = adjustedContentWidth / centerW + local adjustedScaleY = adjustedContentHeight / centerH + + -- TOP/BOTTOM EDGES (stretch horizontally, scale vertically) + if adjustedContentWidth > 0 then + local topCenterScaled = getScaledRegion("topCenter", regions.topCenter, regions.topCenter.w, scaledTop) + local bottomCenterScaled = getScaledRegion("bottomCenter", regions.bottomCenter, regions.bottomCenter.w, scaledBottom) + + love.graphics.draw(topCenterScaled, x + scaledLeft, y, 0, adjustedScaleX, 1) + love.graphics.draw(bottomCenterScaled, x + scaledLeft, y + height - scaledBottom, 0, adjustedScaleX, 1) + end + + -- LEFT/RIGHT EDGES (stretch vertically, scale horizontally) + if adjustedContentHeight > 0 then + local middleLeftScaled = getScaledRegion("middleLeft", regions.middleLeft, scaledLeft, regions.middleLeft.h) + local middleRightScaled = getScaledRegion("middleRight", regions.middleRight, scaledRight, regions.middleRight.h) + + love.graphics.draw(middleLeftScaled, x, y + scaledTop, 0, 1, adjustedScaleY) + love.graphics.draw(middleRightScaled, x + width - scaledRight, y + scaledTop, 0, 1, adjustedScaleY) + end + + -- CENTER (stretch both dimensions, no scaling) + if adjustedContentWidth > 0 and adjustedContentHeight > 0 then + love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + scaledLeft, y + scaledTop, 0, adjustedScaleX, adjustedScaleY) + end + else + -- Original rendering logic (no scaling) + -- CORNERS (no scaling - 1:1 pixel perfect) + love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y) + love.graphics.draw(atlas, makeQuad(regions.topRight), x + left + contentWidth, y) + love.graphics.draw(atlas, makeQuad(regions.bottomLeft), x, y + top + contentHeight) + love.graphics.draw(atlas, makeQuad(regions.bottomRight), x + left + contentWidth, y + top + contentHeight) + + -- TOP/BOTTOM EDGES (stretch horizontally only) + if contentWidth > 0 then + love.graphics.draw(atlas, makeQuad(regions.topCenter), x + left, y, 0, scaleX, 1) + love.graphics.draw(atlas, makeQuad(regions.bottomCenter), x + left, y + top + contentHeight, 0, scaleX, 1) + end + + -- LEFT/RIGHT EDGES (stretch vertically only) + if contentHeight > 0 then + love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + top, 0, 1, scaleY) + love.graphics.draw(atlas, makeQuad(regions.middleRight), x + left + contentWidth, y + top, 0, 1, scaleY) + end + + -- CENTER (stretch both dimensions) + if contentWidth > 0 and contentHeight > 0 then + love.graphics.draw(atlas, makeQuad(regions.middleCenter), x + left, y + top, 0, scaleX, scaleY) + end + end + + -- Reset color + love.graphics.setColor(1, 1, 1, 1) +end + +return NineSlice diff --git a/flexlove/RoundedRect.lua b/flexlove/RoundedRect.lua new file mode 100644 index 0000000..51c074b --- /dev/null +++ b/flexlove/RoundedRect.lua @@ -0,0 +1,95 @@ +--[[ +RoundedRect - Rounded Rectangle Helper for FlexLove +Provides functions for generating and drawing rounded rectangles with per-corner radius control. +]] + +local RoundedRect = {} + +--- Generate points for a rounded rectangle +---@param x number +---@param y number +---@param width number +---@param height number +---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} +---@param segments number? -- Number of segments per corner arc (default: 10) +---@return table -- Array of vertices for love.graphics.polygon +function RoundedRect.getPoints(x, y, width, height, cornerRadius, segments) + segments = segments or 10 + local points = {} + + -- Helper to add arc points + local function addArc(cx, cy, radius, startAngle, endAngle) + if radius <= 0 then + table.insert(points, cx) + table.insert(points, cy) + return + end + + for i = 0, segments do + local angle = startAngle + (endAngle - startAngle) * (i / segments) + table.insert(points, cx + math.cos(angle) * radius) + table.insert(points, cy + math.sin(angle) * radius) + end + end + + local r1 = math.min(cornerRadius.topLeft, width / 2, height / 2) + local r2 = math.min(cornerRadius.topRight, width / 2, height / 2) + local r3 = math.min(cornerRadius.bottomRight, width / 2, height / 2) + local r4 = math.min(cornerRadius.bottomLeft, width / 2, height / 2) + + -- Top-right corner + addArc(x + width - r2, y + r2, r2, -math.pi / 2, 0) + + -- Bottom-right corner + addArc(x + width - r3, y + height - r3, r3, 0, math.pi / 2) + + -- Bottom-left corner + addArc(x + r4, y + height - r4, r4, math.pi / 2, math.pi) + + -- Top-left corner + addArc(x + r1, y + r1, r1, math.pi, math.pi * 1.5) + + return points +end + +--- Draw a filled rounded rectangle +---@param mode string -- "fill" or "line" +---@param x number +---@param y number +---@param width number +---@param height number +---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} +function RoundedRect.draw(mode, x, y, width, height, cornerRadius) + -- Check if any corners are rounded + local hasRoundedCorners = cornerRadius.topLeft > 0 or cornerRadius.topRight > 0 or cornerRadius.bottomLeft > 0 or cornerRadius.bottomRight > 0 + + if not hasRoundedCorners then + -- No rounded corners, use regular rectangle + love.graphics.rectangle(mode, x, y, width, height) + return + end + + local points = RoundedRect.getPoints(x, y, width, height, cornerRadius) + + if mode == "fill" then + love.graphics.polygon("fill", points) + else + -- For line mode, draw the outline + love.graphics.polygon("line", points) + end +end + +--- Create a stencil function for rounded rectangle clipping +---@param x number +---@param y number +---@param width number +---@param height number +---@param cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} +---@return function +function RoundedRect.stencilFunction(x, y, width, height, cornerRadius) + return function() + RoundedRect.draw("fill", x, y, width, height, cornerRadius) + end +end + +return RoundedRect diff --git a/flexlove/Theme.lua b/flexlove/Theme.lua new file mode 100644 index 0000000..439bc0a --- /dev/null +++ b/flexlove/Theme.lua @@ -0,0 +1,493 @@ +--[[ +Theme - Theme System for FlexLove +Manages theme loading, registration, and component/color/font access. +Supports 9-patch images, component states, and dynamic theme switching. +]] + +local Color = require("flexlove.Color") +local NinePatchParser = require("flexlove.NinePatchParser") +local ImageScaler = require("flexlove.ImageScaler") + +--- Standardized error message formatter +---@param module string -- Module name (e.g., "Color", "Theme", "Units") +---@param message string -- Error message +---@return string -- Formatted error message +local function formatError(module, message) + return string.format("[FlexLove.%s] %s", module, message) +end + +--- Auto-detect the base path where FlexLove is located +---@return string modulePath, string filesystemPath +local function getFlexLoveBasePath() + -- Get debug info to find where this file is loaded from + local info = debug.getinfo(1, "S") + if info and info.source then + local source = info.source + -- Remove leading @ if present + if source:sub(1, 1) == "@" then + source = source:sub(2) + end + + -- Extract the directory path (remove Theme.lua and flexlove/) + local filesystemPath = source:match("(.*/)") + if filesystemPath then + -- Store the original filesystem path for loading assets + local fsPath = filesystemPath + -- Remove leading ./ if present + fsPath = fsPath:gsub("^%./", "") + -- Remove trailing / + fsPath = fsPath:gsub("/$", "") + -- Remove the flexlove subdirectory to get back to base + fsPath = fsPath:gsub("/flexlove$", "") + + -- Convert filesystem path to Lua module path + local modulePath = fsPath:gsub("/", ".") + + return modulePath, fsPath + end + end + + -- Fallback: try common paths + return "libs", "libs" +end + +-- Store the base paths when module loads +local FLEXLOVE_BASE_PATH, FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath() + +--- Helper function to resolve image paths relative to FlexLove +---@param imagePath string +---@return string +local function resolveImagePath(imagePath) + -- If path is already absolute or starts with known LÖVE paths, use as-is + if imagePath:match("^/") or imagePath:match("^[A-Z]:") then + return imagePath + end + + -- Otherwise, make it relative to FlexLove's location + return FLEXLOVE_FILESYSTEM_PATH .. "/" .. imagePath +end + +--- Safely load an image with error handling +--- Returns both Image and ImageData to avoid deprecated getData() API +---@param imagePath string +---@return love.Image?, love.ImageData?, string? -- Returns image, imageData, or nil with error message +local function safeLoadImage(imagePath) + local success, imageData = pcall(function() + return love.image.newImageData(imagePath) + end) + + if not success then + local errorMsg = string.format("[FlexLove] Failed to load image data: %s - %s", imagePath, tostring(imageData)) + print(errorMsg) + return nil, nil, errorMsg + end + + local imageSuccess, image = pcall(function() + return love.graphics.newImage(imageData) + end) + + if imageSuccess then + return image, imageData, nil + else + local errorMsg = string.format("[FlexLove] Failed to create image: %s - %s", imagePath, tostring(image)) + print(errorMsg) + return nil, nil, errorMsg + end +end + +--- Validate theme definition structure +---@param definition ThemeDefinition +---@return boolean, string? -- Returns true if valid, or false with error message +local function validateThemeDefinition(definition) + if not definition then + return false, "Theme definition is nil" + end + + if type(definition) ~= "table" then + return false, "Theme definition must be a table" + end + + if not definition.name or type(definition.name) ~= "string" then + return false, "Theme must have a 'name' field (string)" + end + + if definition.components and type(definition.components) ~= "table" then + return false, "Theme 'components' must be a table" + end + + if definition.colors and type(definition.colors) ~= "table" then + return false, "Theme 'colors' must be a table" + end + + if definition.fonts and type(definition.fonts) ~= "table" then + return false, "Theme 'fonts' must be a table" + end + + return true, nil +end + +---@class ThemeRegion +---@field x number -- X position in atlas +---@field y number -- Y position in atlas +---@field w number -- Width in atlas +---@field h number -- Height in atlas + +---@class ThemeComponent +---@field atlas string|love.Image? -- Optional: component-specific atlas (overrides theme atlas). Files ending in .9.png are auto-parsed +---@field insets {left:number, top:number, right:number, bottom:number}? -- Optional: 9-patch insets (auto-extracted from .9.png files or manually defined) +---@field regions {topLeft:ThemeRegion, topCenter:ThemeRegion, topRight:ThemeRegion, middleLeft:ThemeRegion, middleCenter:ThemeRegion, middleRight:ThemeRegion, bottomLeft:ThemeRegion, bottomCenter:ThemeRegion, bottomRight:ThemeRegion} +---@field stretch {horizontal:table, vertical:table} +---@field states table? +---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: multiplier for auto-sized content dimensions +---@field scaleCorners number? -- Optional: scale multiplier for non-stretched regions (corners/edges). E.g., 2 = 2x size. Default: nil (no scaling) +---@field scalingAlgorithm "nearest"|"bilinear"? -- Optional: scaling algorithm for non-stretched regions. Default: "bilinear" +---@field _loadedAtlas string|love.Image? -- Internal: cached loaded atlas image +---@field _loadedAtlasData love.ImageData? -- Internal: cached loaded atlas ImageData for pixel access +---@field _ninePatchData {insets:table, contentPadding:table, stretchX:table, stretchY:table}? -- Internal: parsed 9-patch data with stretch regions and content padding +---@field _scaledRegionCache table? -- Internal: cache for scaled corner/edge images + +---@class FontFamily +---@field path string -- Path to the font file (relative to FlexLove or absolute) +---@field _loadedFont love.Font? -- Internal: cached loaded font + +---@class ThemeDefinition +---@field name string +---@field atlas string|love.Image? -- Optional: global atlas (can be overridden per component) +---@field components table +---@field colors table? +---@field fonts table? -- Optional: font family definitions (name -> path) +---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions + +---@class Theme +---@field name string +---@field atlas love.Image? -- Optional: global atlas +---@field atlasData love.ImageData? +---@field components table +---@field colors table +---@field fonts table -- Font family definitions +---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Optional: default multiplier for auto-sized content dimensions +local Theme = {} +Theme.__index = Theme + +-- Global theme registry +local themes = {} +local activeTheme = nil + +function Theme.new(definition) + -- Validate theme definition + local valid, err = validateThemeDefinition(definition) + if not valid then + error("[FlexLove] Invalid theme definition: " .. tostring(err)) + end + + local self = setmetatable({}, Theme) + self.name = definition.name + + -- Load global atlas if it's a string path + if definition.atlas then + if type(definition.atlas) == "string" then + local resolvedPath = resolveImagePath(definition.atlas) + local image, imageData, loaderr = safeLoadImage(resolvedPath) + if image then + self.atlas = image + self.atlasData = imageData + else + print("[FlexLove] Warning: Failed to load global atlas for theme '" .. definition.name .. "'" .. "(" .. loaderr .. ")") + end + else + self.atlas = definition.atlas + end + end + + self.components = definition.components or {} + self.colors = definition.colors or {} + self.fonts = definition.fonts or {} + self.contentAutoSizingMultiplier = definition.contentAutoSizingMultiplier or nil + + -- Helper function to strip 1-pixel guide border from 9-patch ImageData + ---@param sourceImageData love.ImageData + ---@return love.ImageData -- New ImageData without guide border + local function stripNinePatchBorder(sourceImageData) + local srcWidth = sourceImageData:getWidth() + local srcHeight = sourceImageData:getHeight() + + -- Content dimensions (excluding 1px border on all sides) + local contentWidth = srcWidth - 2 + local contentHeight = srcHeight - 2 + + if contentWidth <= 0 or contentHeight <= 0 then + error(formatError("NinePatch", "Image too small to strip border")) + end + + -- Create new ImageData for content only + local strippedImageData = love.image.newImageData(contentWidth, contentHeight) + + -- Copy pixels from source (1,1) to (width-2, height-2) + for y = 0, contentHeight - 1 do + for x = 0, contentWidth - 1 do + local r, g, b, a = sourceImageData:getPixel(x + 1, y + 1) + strippedImageData:setPixel(x, y, r, g, b, a) + end + end + + return strippedImageData + end + + -- Helper function to load atlas with 9-patch support + local function loadAtlasWithNinePatch(comp, atlasPath, errorContext) + ---@diagnostic disable-next-line + local resolvedPath = resolveImagePath(atlasPath) + ---@diagnostic disable-next-line + local is9Patch = not comp.insets and atlasPath:match("%.9%.png$") + + if is9Patch then + local parseResult, parseErr = NinePatchParser.parse(resolvedPath) + if parseResult then + comp.insets = parseResult.insets + comp._ninePatchData = parseResult + else + print("[FlexLove] Warning: Failed to parse 9-patch " .. errorContext .. ": " .. tostring(parseErr)) + end + end + + local image, imageData, loaderr = safeLoadImage(resolvedPath) + if image then + -- Strip guide border for 9-patch images + if is9Patch and imageData then + local strippedImageData = stripNinePatchBorder(imageData) + local strippedImage = love.graphics.newImage(strippedImageData) + comp._loadedAtlas = strippedImage + comp._loadedAtlasData = strippedImageData + else + comp._loadedAtlas = image + comp._loadedAtlasData = imageData + end + else + print("[FlexLove] Warning: Failed to load atlas " .. errorContext .. ": " .. tostring(loaderr)) + end + end + + -- Helper function to create regions from insets + local function createRegionsFromInsets(comp, fallbackAtlas) + local atlasImage = comp._loadedAtlas or fallbackAtlas + if not atlasImage or type(atlasImage) == "string" then + return + end + + local imgWidth, imgHeight = atlasImage:getDimensions() + local left = comp.insets.left or 0 + local top = comp.insets.top or 0 + local right = comp.insets.right or 0 + local bottom = comp.insets.bottom or 0 + + -- No offsets needed - guide border has been stripped for 9-patch images + local centerWidth = imgWidth - left - right + local centerHeight = imgHeight - top - bottom + + comp.regions = { + topLeft = { x = 0, y = 0, w = left, h = top }, + topCenter = { x = left, y = 0, w = centerWidth, h = top }, + topRight = { x = left + centerWidth, y = 0, w = right, h = top }, + middleLeft = { x = 0, y = top, w = left, h = centerHeight }, + middleCenter = { x = left, y = top, w = centerWidth, h = centerHeight }, + middleRight = { x = left + centerWidth, y = top, w = right, h = centerHeight }, + bottomLeft = { x = 0, y = top + centerHeight, w = left, h = bottom }, + bottomCenter = { x = left, y = top + centerHeight, w = centerWidth, h = bottom }, + bottomRight = { x = left + centerWidth, y = top + centerHeight, w = right, h = bottom }, + } + end + + -- Load component-specific atlases and process 9-patch definitions + for componentName, component in pairs(self.components) do + if component.atlas then + if type(component.atlas) == "string" then + loadAtlasWithNinePatch(component, component.atlas, "for component '" .. componentName .. "'") + else + -- Direct Image object (no ImageData available - scaleCorners won't work) + component._loadedAtlas = component.atlas + end + end + + if component.insets then + createRegionsFromInsets(component, self.atlas) + end + + if component.states then + for stateName, stateComponent in pairs(component.states) do + if stateComponent.atlas then + if type(stateComponent.atlas) == "string" then + loadAtlasWithNinePatch(stateComponent, stateComponent.atlas, "for state '" .. stateName .. "'") + else + -- Direct Image object (no ImageData available - scaleCorners won't work) + stateComponent._loadedAtlas = stateComponent.atlas + end + end + + if stateComponent.insets then + createRegionsFromInsets(stateComponent, component._loadedAtlas or self.atlas) + end + end + end + end + + return self +end + +--- Load a theme from a Lua file +---@param path string -- Path to theme definition file (e.g., "space" or "mytheme") +---@return Theme +function Theme.load(path) + local definition + + -- Build the theme module path relative to FlexLove + local themePath = FLEXLOVE_BASE_PATH .. ".themes." .. path + + local success, result = pcall(function() + return require(themePath) + end) + + if success then + definition = result + else + -- Fallback: try as direct path + success, result = pcall(function() + return require(path) + end) + + if success then + definition = result + else + error("Failed to load theme '" .. path .. "'\nTried: " .. themePath .. "\nError: " .. tostring(result)) + end + end + + local theme = Theme.new(definition) + -- Register theme by both its display name and load path + themes[theme.name] = theme + themes[path] = theme + + return theme +end + +--- Set the active theme +---@param themeOrName Theme|string +function Theme.setActive(themeOrName) + if type(themeOrName) == "string" then + -- Try to load if not already loaded + if not themes[themeOrName] then + Theme.load(themeOrName) + end + activeTheme = themes[themeOrName] + else + activeTheme = themeOrName + end + + if not activeTheme then + error("Failed to set active theme: " .. tostring(themeOrName)) + end +end + +--- Get the active theme +---@return Theme? +function Theme.getActive() + return activeTheme +end + +--- Get a component from the active theme +---@param componentName string -- Name of the component (e.g., "button", "panel") +---@param state string? -- Optional state (e.g., "hover", "pressed", "disabled") +---@return ThemeComponent? -- Returns component or nil if not found +function Theme.getComponent(componentName, state) + if not activeTheme then + return nil + end + + local component = activeTheme.components[componentName] + if not component then + return nil + end + + -- Check for state-specific override + if state and component.states and component.states[state] then + return component.states[state] + end + + return component +end + +--- Get a font from the active theme +---@param fontName string -- Name of the font family (e.g., "default", "heading") +---@return string? -- Returns font path or nil if not found +function Theme.getFont(fontName) + if not activeTheme then + return nil + end + + return activeTheme.fonts and activeTheme.fonts[fontName] +end + +--- Get a color from the active theme +---@param colorName string -- Name of the color (e.g., "primary", "secondary") +---@return Color? -- Returns Color instance or nil if not found +function Theme.getColor(colorName) + if not activeTheme then + return nil + end + + return activeTheme.colors and activeTheme.colors[colorName] +end + +--- Check if a theme is currently active +---@return boolean -- Returns true if a theme is active +function Theme.hasActive() + return activeTheme ~= nil +end + +--- Get all registered theme names +---@return table -- Array of theme names +function Theme.getRegisteredThemes() + local themeNames = {} + for name, _ in pairs(themes) do + table.insert(themeNames, name) + end + return themeNames +end + +--- Get all available color names from the active theme +---@return table|nil -- Array of color names, or nil if no theme active +function Theme.getColorNames() + if not activeTheme or not activeTheme.colors then + return nil + end + + local colorNames = {} + for name, _ in pairs(activeTheme.colors) do + table.insert(colorNames, name) + end + return colorNames +end + +--- Get all colors from the active theme +---@return table|nil -- Table of all colors, or nil if no theme active +function Theme.getAllColors() + if not activeTheme then + return nil + end + + return activeTheme.colors +end + +--- Get a color with a fallback if not found +---@param colorName string -- Name of the color to retrieve +---@param fallback Color|nil -- Fallback color if not found (default: white) +---@return Color -- The color or fallback +function Theme.getColorOrDefault(colorName, fallback) + local color = Theme.getColor(colorName) + if color then + return color + end + + return fallback or Color.new(1, 1, 1, 1) +end + +return Theme diff --git a/flexlove/Units.lua b/flexlove/Units.lua new file mode 100644 index 0000000..551d424 --- /dev/null +++ b/flexlove/Units.lua @@ -0,0 +1,180 @@ +-- ==================== +-- Units System +-- ==================== + +--- Unit parsing and viewport calculations +local Units = {} + +--- Parse a unit value (string or number) into value and unit type +---@param value string|number +---@return number, string -- Returns numeric value and unit type ("px", "%", "vw", "vh") +function Units.parse(value) + if type(value) == "number" then + return value, "px" + end + + if type(value) ~= "string" then + -- Fallback to 0px for invalid types + return 0, "px" + end + + -- Match number followed by optional unit + local numStr, unit = value:match("^([%-]?[%d%.]+)(.*)$") + if not numStr then + -- Fallback to 0px for invalid format + return 0, "px" + end + + local num = tonumber(numStr) + if not num then + -- Fallback to 0px for invalid numeric value + return 0, "px" + end + + -- Default to pixels if no unit specified + if unit == "" then + unit = "px" + end + + local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true } + if not validUnits[unit] then + return num, "px" + end + + return num, unit +end + +--- Convert relative units to pixels based on viewport and parent dimensions +---@param value number -- Numeric value to convert +---@param unit string -- Unit type ("px", "%", "vw", "vh", "ew", "eh") +---@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) +---@return number -- Resolved pixel value +---@throws Error if unit type is unknown or percentage used without parent size +function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize) + if unit == "px" then + return value + elseif unit == "%" then + if not parentSize then + error(formatError("Units", "Percentage units require parent dimension")) + end + return (value / 100) * parentSize + elseif unit == "vw" then + return (value / 100) * viewportWidth + elseif unit == "vh" then + return (value / 100) * viewportHeight + else + error(formatError("Units", string.format("Unknown unit type: '%s'. Valid units: px, %%, vw, vh, ew, eh", unit))) + end +end + +---@return number, number -- width, height +function Units.getViewport() + -- Return cached viewport if available (only during resize operations) + if Gui and Gui._cachedViewport and Gui._cachedViewport.width > 0 then + return Gui._cachedViewport.width, Gui._cachedViewport.height + end + + -- Query viewport dimensions normally + if love.graphics and love.graphics.getDimensions then + return love.graphics.getDimensions() + else + local w, h = love.window.getMode() + return w, h + end +end + +--- Apply base scaling to a value +---@param value number +---@param axis "x"|"y" -- Which axis to scale on +---@param scaleFactors {x:number, y:number} +---@return number +function Units.applyBaseScale(value, axis, scaleFactors) + if axis == "x" then + return value * scaleFactors.x + else + return value * scaleFactors.y + end +end + +--- Resolve units for spacing properties (padding, margin) +---@param spacingProps table? +---@param parentWidth number +---@param parentHeight number +---@return table -- Resolved spacing with top, right, bottom, left in pixels +function Units.resolveSpacing(spacingProps, parentWidth, parentHeight) + if not spacingProps then + return { top = 0, right = 0, bottom = 0, left = 0 } + end + + local viewportWidth, viewportHeight = Units.getViewport() + local result = {} + + -- Handle shorthand properties first + local vertical = spacingProps.vertical + local horizontal = spacingProps.horizontal + + if vertical then + if type(vertical) == "string" then + local value, unit = Units.parse(vertical) + vertical = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) + end + end + + if horizontal then + if type(horizontal) == "string" then + local value, unit = Units.parse(horizontal) + horizontal = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) + end + end + + -- Handle individual sides + for _, side in ipairs({ "top", "right", "bottom", "left" }) do + local value = spacingProps[side] + if value then + if type(value) == "string" 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) + else + result[side] = value + end + else + -- Use fallbacks + if side == "top" or side == "bottom" then + result[side] = vertical or 0 + else + result[side] = horizontal or 0 + end + end + end + + return result +end + +--- Check if a unit string is valid +---@param unitStr string -- Unit string to validate (e.g., "10px", "50%", "20vw") +---@return boolean -- Returns true if unit string is valid +function Units.isValid(unitStr) + if type(unitStr) ~= "string" then + return false + end + + local _, unit = Units.parse(unitStr) + local validUnits = { px = true, ["%"] = true, vw = true, vh = true, ew = true, eh = true } + return validUnits[unit] == true +end + +--- Parse and resolve a unit value in one call +---@param value string|number -- Value to parse and resolve +---@param viewportWidth number -- Current viewport width +---@param viewportHeight number -- Current viewport height +---@param parentSize number? -- Parent dimension for percentage units +---@return number -- Resolved pixel value +function Units.parseAndResolve(value, viewportWidth, viewportHeight, parentSize) + local numValue, unit = Units.parse(value) + return Units.resolve(numValue, unit, viewportWidth, viewportHeight, parentSize) +end + +return Units diff --git a/flexlove/constants.lua b/flexlove/constants.lua new file mode 100644 index 0000000..d8ad7d0 --- /dev/null +++ b/flexlove/constants.lua @@ -0,0 +1,16 @@ +-- Text size preset mappings (in vh units for auto-scaling) +local TEXT_SIZE_PRESETS = { + ["2xs"] = 0.75, -- 0.75vh + xxs = 0.75, -- 0.75vh + xs = 1.25, -- 1.25vh + sm = 1.75, -- 1.75vh + md = 2.25, -- 2.25vh (default) + lg = 2.75, -- 2.75vh + xl = 3.5, -- 3.5vh + xxl = 4.5, -- 4.5vh + ["2xl"] = 4.5, -- 4.5vh + ["3xl"] = 5.0, -- 5vh + ["4xl"] = 7.0, -- 7vh +} + +return { TEXT_SIZE_PRESETS } diff --git a/flexlove/utils.lua b/flexlove/utils.lua new file mode 100644 index 0000000..a9fa94d --- /dev/null +++ b/flexlove/utils.lua @@ -0,0 +1,253 @@ +local enums = { + ---@enum TextAlign + TextAlign = { START = "start", CENTER = "center", END = "end", JUSTIFY = "justify" }, + ---@enum Positioning + Positioning = { ABSOLUTE = "absolute", RELATIVE = "relative", FLEX = "flex", GRID = "grid" }, + ---@enum FlexDirection + FlexDirection = { HORIZONTAL = "horizontal", VERTICAL = "vertical" }, + ---@enum JustifyContent + JustifyContent = { + FLEX_START = "flex-start", + CENTER = "center", + SPACE_AROUND = "space-around", + FLEX_END = "flex-end", + SPACE_EVENLY = "space-evenly", + SPACE_BETWEEN = "space-between", + }, + ---@enum JustifySelf + JustifySelf = { + AUTO = "auto", + FLEX_START = "flex-start", + CENTER = "center", + FLEX_END = "flex-end", + SPACE_AROUND = "space-around", + SPACE_EVENLY = "space-evenly", + SPACE_BETWEEN = "space-between", + }, + ---@enum AlignItems + AlignItems = { + STRETCH = "stretch", + FLEX_START = "flex-start", + FLEX_END = "flex-end", + CENTER = "center", + BASELINE = "baseline", + }, + ---@enum AlignSelf + AlignSelf = { + AUTO = "auto", + STRETCH = "stretch", + FLEX_START = "flex-start", + FLEX_END = "flex-end", + CENTER = "center", + BASELINE = "baseline", + }, + ---@enum AlignContent + AlignContent = { + STRETCH = "stretch", + FLEX_START = "flex-start", + FLEX_END = "flex-end", + CENTER = "center", + SPACE_BETWEEN = "space-between", + SPACE_AROUND = "space-around", + }, + ---@enum FlexWrap + FlexWrap = { NOWRAP = "nowrap", WRAP = "wrap", WRAP_REVERSE = "wrap-reverse" }, + ---@enum TextSize + TextSize = { + XXS = "xxs", + XS = "xs", + SM = "sm", + MD = "md", + LG = "lg", + XL = "xl", + XXL = "xxl", + XL3 = "3xl", + XL4 = "4xl", + }, +} + +---@class ElementProps +---@field id string? +---@field parent Element? -- Parent element for hierarchical structure +---@field x number|string? -- X coordinate of the element (default: 0) +---@field y number|string? -- Y coordinate of the element (default: 0) +---@field z number? -- Z-index for layering (default: 0) +---@field width number|string? -- Width of the element (default: calculated automatically) +---@field height number|string? -- Height of the element (default: calculated automatically) +---@field top number|string? -- Offset from top edge (CSS-style positioning) +---@field right number|string? -- Offset from right edge (CSS-style positioning) +---@field bottom number|string? -- Offset from bottom edge (CSS-style positioning) +---@field left number|string? -- Offset from left edge (CSS-style positioning) +---@field border Border? -- Border configuration for the element +---@field borderColor Color? -- Color of the border (default: black) +---@field opacity number? +---@field backgroundColor Color? -- Background color (default: transparent) +---@field cornerRadius number|{topLeft:number?, topRight:number?, bottomLeft:number?, bottomRight:number?}? -- Corner radius: number (all corners) or table for individual corners (default: 0) +---@field gap number|string? -- Space between children elements (default: 10) +---@field padding {top:number|string?, right:number|string?, bottom:number|string?, left:number|string?, horizontal: number|string?, vertical:number|string?}? -- Padding around children (default: {top=0, right=0, bottom=0, left=0}) +---@field margin {top:number|string?, right:number|string?, bottom:number|string?, left:number|string?, horizontal: number|string?, vertical:number|string?}? -- Margin around children (default: {top=0, right=0, bottom=0, left=0}) +---@field text string? -- Text content to display (default: nil) +---@field titleColor Color? -- Color of the text content (default: black) +---@field textAlign TextAlign? -- Alignment of the text content (default: START) +---@field textColor Color? -- Color of the text content (default: black) +---@field textSize number|string? -- Font size: number (px), string with units ("2vh", "10%"), or preset ("xxs"|"xs"|"sm"|"md"|"lg"|"xl"|"xxl"|"3xl"|"4xl") (default: "md") +---@field minTextSize number? +---@field maxTextSize number? +---@field fontFamily string? -- Font family name from theme or path to font file (default: theme default or system default) +---@field autoScaleText boolean? -- Whether text should auto-scale with window size (default: true) +---@field positioning Positioning? -- Layout positioning mode (default: RELATIVE) +---@field flexDirection FlexDirection? -- Direction of flex layout (default: HORIZONTAL) +---@field justifyContent JustifyContent? -- Alignment of items along main axis (default: FLEX_START) +---@field alignItems AlignItems? -- Alignment of items along cross axis (default: STRETCH) +---@field alignContent AlignContent? -- Alignment of lines in multi-line flex containers (default: STRETCH) +---@field flexWrap FlexWrap? -- Whether children wrap to multiple lines (default: NOWRAP) +---@field justifySelf JustifySelf? -- Alignment of the item itself along main axis (default: AUTO) +---@field alignSelf AlignSelf? -- Alignment of the item itself along cross axis (default: AUTO) +---@field callback fun(element:Element, event:InputEvent)? -- Callback function for interaction events +---@field transform table? -- Transform properties for animations and styling +---@field transition table? -- Transition settings for animations +---@field gridRows number? -- Number of rows in the grid (default: 1) +---@field gridColumns number? -- Number of columns in the grid (default: 1) +---@field columnGap number|string? -- Gap between grid columns +---@field rowGap number|string? -- Gap between grid rows +---@field theme string? -- Theme name to use (e.g., "space", "dark"). Defaults to theme from Gui.init() +---@field themeComponent string? -- Theme component to use (e.g., "panel", "button", "input"). If nil, no theme is applied +---@field disabled boolean? -- Whether the element is disabled (default: false) +---@field active boolean? -- Whether the element is active/focused (for inputs, default: false) +---@field disableHighlight boolean? -- Whether to disable the pressed state highlight overlay (default: false) +---@field contentAutoSizingMultiplier {width:number?, height:number?}? -- Multiplier for auto-sized content dimensions (default: sourced from theme) +---@field scaleCorners number? -- Scale multiplier for 9-slice corners/edges. E.g., 2 = 2x size (overrides theme setting) +---@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-slice corners: "nearest" (sharp/pixelated) or "bilinear" (smooth) (overrides theme setting) +---@field contentBlur {intensity:number, quality:number}? -- Blur the element's content including children (intensity: 0-100, quality: 1-10, default: nil) +---@field backdropBlur {intensity:number, quality:number}? -- Blur content behind the element (intensity: 0-100, quality: 1-10, default: nil) +---@field editable boolean? -- Whether the element is editable (default: false) +---@field multiline boolean? -- Whether the element supports multiple lines (default: false) +---@field textWrap boolean|"word"|"char"? -- Text wrapping mode (default: false for single-line, "word" for multi-line) +---@field maxLines number? -- Maximum number of lines (default: nil) +---@field maxLength number? -- Maximum text length in characters (default: nil) +---@field placeholder string? -- Placeholder text when empty (default: nil) +---@field passwordMode boolean? -- Whether to display text as password (default: false) +---@field inputType "text"|"number"|"email"|"url"? -- Input type for validation (default: "text") +---@field textOverflow "clip"|"ellipsis"|"scroll"? -- Text overflow behavior (default: "clip") +---@field scrollable boolean? -- Whether text is scrollable (default: false for single-line, true for multi-line) +---@field autoGrow boolean? -- Whether element auto-grows with text (default: false) +---@field selectOnFocus boolean? -- Whether to select all text on focus (default: false) +---@field cursorColor Color? -- Cursor color (default: nil, uses textColor) +---@field selectionColor Color? -- Selection background color (default: nil, uses theme or default) +---@field cursorBlinkRate number? -- Cursor blink rate in seconds (default: 0.5) +---@field overflow "visible"|"hidden"|"scroll"|"auto"? -- Overflow behavior (default: "visible") +---@field overflowX "visible"|"hidden"|"scroll"|"auto"? -- X-axis overflow (overrides overflow) +---@field overflowY "visible"|"hidden"|"scroll"|"auto"? -- Y-axis overflow (overrides overflow) +---@field scrollbarWidth number? -- Width of scrollbar track in pixels (default: 12) +---@field scrollbarColor Color? -- Scrollbar thumb color +---@field scrollbarTrackColor Color? -- Scrollbar track color +---@field scrollbarRadius number? -- Corner radius for scrollbar (default: 6) +---@field scrollbarPadding number? -- Padding between scrollbar and edge (default: 2) +---@field scrollSpeed number? -- Pixels per wheel notch (default: 20) + +---@class Border +---@field top boolean? +---@field right boolean? +---@field bottom boolean? +---@field left boolean? + +--- Get current keyboard modifiers state +---@return {shift:boolean, ctrl:boolean, alt:boolean, super:boolean} +local function getModifiers() + return { + shift = love.keyboard.isDown("lshift", "rshift"), + ctrl = love.keyboard.isDown("lctrl", "rctrl"), + alt = love.keyboard.isDown("lalt", "ralt"), + ---@diagnostic disable-next-line + super = love.keyboard.isDown("lgui", "rgui"), -- cmd/windows key + } +end + +local TEXT_SIZE_PRESETS = { + ["2xs"] = 0.75, -- 0.75vh + xxs = 0.75, -- 0.75vh + xs = 1.25, -- 1.25vh + sm = 1.75, -- 1.75vh + md = 2.25, -- 2.25vh (default) + lg = 2.75, -- 2.75vh + xl = 3.5, -- 3.5vh + xxl = 4.5, -- 4.5vh + ["2xl"] = 4.5, -- 4.5vh + ["3xl"] = 5.0, -- 5vh + ["4xl"] = 7.0, -- 7vh +} + +-- ==================== +-- Text Size Utilities +-- ==================== + +--- Resolve text size preset to viewport units +---@param sizeValue string|number +---@return number?, string? -- Returns value and unit ("vh" for presets, original unit otherwise) +local function resolveTextSizePreset(sizeValue) + if type(sizeValue) == "string" then + -- Check if it's a preset + local preset = TEXT_SIZE_PRESETS[sizeValue] + if preset then + return preset, "vh" + end + end + -- Not a preset, return nil to indicate normal parsing should occur + return nil, nil +end + +local FONT_CACHE = {} +local FONT_CACHE_MAX_SIZE = 50 -- Limit cache size to prevent unbounded growth +local FONT_CACHE_ORDER = {} -- Track access order for LRU eviction + +--- Create or get a font from cache +---@param size number +---@param fontPath string? -- Optional: path to font file +---@return love.Font +function FONT_CACHE.get(size, fontPath) + -- Create cache key from size and font path + local cacheKey = fontPath and (fontPath .. "_" .. tostring(size)) or tostring(size) + + if not FONT_CACHE[cacheKey] then + if fontPath then + -- Load custom font + local resolvedPath = resolveImagePath(fontPath) + -- Note: love.graphics.newFont signature is (path, size) for custom fonts + local success, font = pcall(love.graphics.newFont, resolvedPath, size) + if success then + FONT_CACHE[cacheKey] = font + else + -- Fallback to default font if custom font fails to load + print("[FlexLove] Failed to load font: " .. fontPath .. " - using default font") + FONT_CACHE[cacheKey] = love.graphics.newFont(size) + end + else + -- Load default font + FONT_CACHE[cacheKey] = love.graphics.newFont(size) + end + + -- Add to access order for LRU tracking + table.insert(FONT_CACHE_ORDER, cacheKey) + + -- Evict oldest entry if cache is full (LRU eviction) + if #FONT_CACHE_ORDER > FONT_CACHE_MAX_SIZE then + local oldestKey = table.remove(FONT_CACHE_ORDER, 1) + FONT_CACHE[oldestKey] = nil + end + end + return FONT_CACHE[cacheKey] +end + +--- Get font for text size (cached) +---@param textSize number? +---@param fontPath string? -- Optional: path to font file +---@return love.Font +function FONT_CACHE.getFont(textSize, fontPath) + if textSize then + return FONT_CACHE.get(textSize, fontPath) + else + return love.graphics.getFont() + end +end + +return { enums, FONT_CACHE, resolveTextSizePreset, getModifiers }