-- 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 ---@param hexWithTag string -- e.g. "#RRGGBB" or "#RRGGBBAA" ---@return Color function Color.fromHex(hexWithTag) local hex = hexWithTag:gsub("#", "") if #hex == 6 then local r = tonumber("0x" .. hex:sub(1, 2)) or 0 local g = tonumber("0x" .. hex:sub(3, 4)) or 0 local b = tonumber("0x" .. hex:sub(5, 6)) or 0 return Color.new(r, g, b, 1) elseif #hex == 8 then local r = tonumber("0x" .. hex:sub(1, 2)) or 0 local g = tonumber("0x" .. hex:sub(3, 4)) or 0 local b = tonumber("0x" .. hex:sub(5, 6)) or 0 local a = tonumber("0x" .. hex:sub(7, 8)) / 255 return Color.new(r, g, b, a) else error("Invalid hex string") end 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) ---@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 _loadedAtlas love.Image? -- Internal: cached loaded atlas image ---@class ThemeDefinition ---@field name string ---@field atlas string|love.Image? -- Optional: global atlas (can be overridden per component) ---@field components table ---@field colors table? ---@class Theme ---@field name string ---@field atlas love.Image? -- Optional: global atlas ---@field components table ---@field colors table 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 --- Create a new theme instance ---@param definition ThemeDefinition ---@return Theme function Theme.new(definition) 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) self.atlas = love.graphics.newImage(resolvedPath) else self.atlas = definition.atlas end end self.components = definition.components or {} self.colors = definition.colors or {} -- Load component-specific atlases for componentName, component in pairs(self.components) do if component.atlas then if type(component.atlas) == "string" then local resolvedPath = resolveImagePath(component.atlas) component._loadedAtlas = love.graphics.newImage(resolvedPath) else component._loadedAtlas = component.atlas end end -- Also load atlases for component states if component.states then for stateName, stateComponent in pairs(component.states) do if stateComponent.atlas then if type(stateComponent.atlas) == "string" then local resolvedPath = resolveImagePath(stateComponent.atlas) stateComponent._loadedAtlas = love.graphics.newImage(resolvedPath) else stateComponent._loadedAtlas = stateComponent.atlas end 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 ---@param state string? ---@return ThemeComponent? 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 -- ==================== -- 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-slice component ---@param component ThemeComponent ---@param atlas love.Image ---@param x number ---@param y number ---@param width number ---@param height number ---@param opacity number? function NineSlice.draw(component, atlas, x, y, width, height, opacity) if not component or not atlas then return end opacity = opacity or 1 love.graphics.setColor(1, 1, 1, opacity) local regions = component.regions -- Calculate dimensions local cornerWidth = regions.topLeft.w local cornerHeight = regions.topLeft.h local rightCornerWidth = regions.topRight.w local rightCornerHeight = regions.topRight.h local bottomLeftHeight = regions.bottomLeft.h local bottomRightHeight = regions.bottomRight.h local bottomLeftWidth = regions.bottomLeft.w local bottomRightWidth = regions.bottomRight.w -- Calculate minimum required dimensions local minWidth = cornerWidth + rightCornerWidth local minHeight = cornerHeight + bottomLeftHeight -- Create quads for each region local atlasWidth, atlasHeight = atlas:getDimensions() -- Helper to create quad local function makeQuad(region) return love.graphics.newQuad(region.x, region.y, region.w, region.h, atlasWidth, atlasHeight) end -- Check if element is too small and needs proportional scaling local scaleDownX = 1 local scaleDownY = 1 if width < minWidth then scaleDownX = width / minWidth end if height < minHeight then scaleDownY = height / minHeight end -- Apply proportional scaling to corner dimensions if needed local scaledCornerWidth = cornerWidth * scaleDownX local scaledRightCornerWidth = rightCornerWidth * scaleDownX local scaledCornerHeight = cornerHeight * scaleDownY local scaledBottomLeftHeight = bottomLeftHeight * scaleDownY local scaledBottomRightHeight = bottomRightHeight * scaleDownY -- Center dimensions (stretchable area) local centerWidth = width - scaledCornerWidth - scaledRightCornerWidth local centerHeight = height - scaledCornerHeight - scaledBottomLeftHeight -- Top-left corner love.graphics.draw(atlas, makeQuad(regions.topLeft), x, y, 0, scaleDownX, scaleDownY) -- Top-right corner love.graphics.draw( atlas, makeQuad(regions.topRight), x + width - scaledRightCornerWidth, y, 0, scaleDownX, scaleDownY ) -- Bottom-left corner love.graphics.draw( atlas, makeQuad(regions.bottomLeft), x, y + height - scaledBottomLeftHeight, 0, scaleDownX, scaleDownY ) -- Bottom-right corner love.graphics.draw( atlas, makeQuad(regions.bottomRight), x + width - scaledRightCornerWidth, y + height - scaledBottomRightHeight, 0, scaleDownX, scaleDownY ) -- Top edge (stretched) if centerWidth > 0 then local scaleX = centerWidth / regions.topCenter.w love.graphics.draw(atlas, makeQuad(regions.topCenter), x + scaledCornerWidth, y, 0, scaleX, scaleDownY) end -- Bottom edge (stretched) if centerWidth > 0 then local scaleX = centerWidth / regions.bottomCenter.w love.graphics.draw( atlas, makeQuad(regions.bottomCenter), x + scaledCornerWidth, y + height - scaledBottomLeftHeight, 0, scaleX, scaleDownY ) end -- Left edge (stretched) if centerHeight > 0 then local scaleY = centerHeight / regions.middleLeft.h love.graphics.draw(atlas, makeQuad(regions.middleLeft), x, y + scaledCornerHeight, 0, scaleDownX, scaleY) end -- Right edge (stretched) if centerHeight > 0 then local scaleY = centerHeight / regions.middleRight.h love.graphics.draw( atlas, makeQuad(regions.middleRight), x + width - scaledRightCornerWidth, y + scaledCornerHeight, 0, scaleDownX, scaleY ) end -- Center (stretched both ways) if centerWidth > 0 and centerHeight > 0 then local scaleX = centerWidth / regions.middleCenter.w local scaleY = centerHeight / regions.middleCenter.h love.graphics.draw( atlas, makeQuad(regions.middleCenter), x + scaledCornerWidth, y + scaledCornerHeight, 0, scaleX, scaleY ) 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 = { 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 ["3xl"] = 5.0, -- 5vh ["4xl"] = 7.0, -- 7vh } local Positioning, FlexDirection, JustifyContent, AlignContent, AlignItems, TextAlign, AlignSelf, JustifySelf, FlexWrap, TextSize = enums.Positioning, enums.FlexDirection, enums.JustifyContent, enums.AlignContent, enums.AlignItems, enums.TextAlign, enums.AlignSelf, enums.JustifySelf, enums.FlexWrap, enums.TextSize -- ==================== -- 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 ---@param unit string ---@param viewportWidth number ---@param viewportHeight number ---@param parentSize number? -- Required for percentage units ---@return number -- Pixel value function Units.resolve(value, unit, viewportWidth, viewportHeight, parentSize) if unit == "px" then return value elseif unit == "%" then if not parentSize then error("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("Unknown unit type: " .. unit) end end ---@return number, number -- width, height function Units.getViewport() -- Try both functions to be compatible with different love versions and test environments 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 -- ==================== -- Grid System -- ==================== --- Simple grid layout calculations local Grid = {} --- Layout grid items within a grid container using simple row/column counts ---@param element Element -- Grid container element function Grid.layoutGridItems(element) local rows = element.gridRows or 1 local columns = element.gridColumns or 1 -- Calculate available space local availableWidth = element.width - element.padding.left - element.padding.right local availableHeight = element.height - element.padding.top - element.padding.bottom -- Get gaps local columnGap = element.columnGap or 0 local rowGap = element.rowGap or 0 -- Calculate cell sizes (equal distribution) local totalColumnGaps = (columns - 1) * columnGap local totalRowGaps = (rows - 1) * rowGap local cellWidth = (availableWidth - totalColumnGaps) / columns local cellHeight = (availableHeight - totalRowGaps) / rows -- Get children that participate in grid layout local gridChildren = {} for _, child in ipairs(element.children) do if not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) then table.insert(gridChildren, child) end end -- Place children in grid cells for i, child in ipairs(gridChildren) do -- Calculate row and column (0-indexed for calculation) local index = i - 1 local col = index % columns local row = math.floor(index / columns) -- Skip if we've exceeded the grid if row >= rows then break end -- Calculate cell position local cellX = element.x + element.padding.left + (col * (cellWidth + columnGap)) local cellY = element.y + element.padding.top + (row * (cellHeight + rowGap)) -- Apply alignment within grid cell (default to stretch) local effectiveAlignItems = element.alignItems or AlignItems.STRETCH -- Stretch child to fill cell by default if effectiveAlignItems == AlignItems.STRETCH or effectiveAlignItems == "stretch" then child.x = cellX child.y = cellY child.width = cellWidth - child.padding.left - child.padding.right child.height = cellHeight - child.padding.top - child.padding.bottom -- Disable auto-sizing when stretched by grid child.autosizing.width = false child.autosizing.height = false elseif effectiveAlignItems == AlignItems.CENTER or effectiveAlignItems == "center" then local childTotalWidth = child.width + child.padding.left + child.padding.right local childTotalHeight = child.height + child.padding.top + child.padding.bottom child.x = cellX + (cellWidth - childTotalWidth) / 2 child.y = cellY + (cellHeight - childTotalHeight) / 2 elseif effectiveAlignItems == AlignItems.FLEX_START or effectiveAlignItems == "flex-start" or effectiveAlignItems == "start" then child.x = cellX child.y = cellY elseif effectiveAlignItems == AlignItems.FLEX_END or effectiveAlignItems == "flex-end" or effectiveAlignItems == "end" then local childTotalWidth = child.width + child.padding.left + child.padding.right local childTotalHeight = child.height + child.padding.top + child.padding.bottom child.x = cellX + cellWidth - childTotalWidth child.y = cellY + cellHeight - childTotalHeight else -- Default to stretch child.x = cellX child.y = cellY child.width = cellWidth - child.padding.left - child.padding.right child.height = cellHeight - child.padding.top - child.padding.bottom -- Disable auto-sizing when stretched by grid child.autosizing.width = false child.autosizing.height = false end -- Layout child's children if it has any if #child.children > 0 then child:layoutChildren() end end end --- Top level GUI manager ---@class Gui ---@field topElements table ---@field baseScale {width:number, height:number}? ---@field scaleFactors {x:number, y:number} ---@field defaultTheme string? -- Default theme name to use for elements local Gui = { topElements = {}, baseScale = nil, scaleFactors = { x = 1.0, y = 1.0 }, defaultTheme = nil, } --- Initialize FlexLove with configuration ---@param config {baseScale?: {width?:number, height?:number}, theme?: string|ThemeDefinition} --Default: {width: 1920, height: 1080} function Gui.init(config) if config.baseScale then Gui.baseScale = { width = config.baseScale.width or 1920, height = config.baseScale.height or 1080, } -- Calculate initial scale factors local currentWidth, currentHeight = Units.getViewport() Gui.scaleFactors.x = currentWidth / Gui.baseScale.width Gui.scaleFactors.y = currentHeight / Gui.baseScale.height end -- Load and set theme if specified if config.theme then local success, err = pcall(function() if type(config.theme) == "string" then -- Load theme by name Theme.load(config.theme) Theme.setActive(config.theme) Gui.defaultTheme = config.theme print("[FlexLove] Theme loaded: " .. config.theme) elseif type(config.theme) == "table" then -- Load theme from definition local theme = Theme.new(config.theme) Theme.setActive(theme) Gui.defaultTheme = theme.name print("[FlexLove] Theme loaded: " .. theme.name) end end) if not success then print("[FlexLove] Failed to load theme: " .. tostring(err)) end end end --- Get current scale factors ---@return number, number -- scaleX, scaleY function Gui.getScaleFactors() return Gui.scaleFactors.x, Gui.scaleFactors.y end function Gui.resize() local newWidth, newHeight = love.window.getMode() -- Update scale factors if base scale is set if Gui.baseScale then Gui.scaleFactors.x = newWidth / Gui.baseScale.width Gui.scaleFactors.y = newHeight / Gui.baseScale.height end for _, win in ipairs(Gui.topElements) do win:resize(newWidth, newHeight) end end function Gui.draw() -- Sort elements by z-index before drawing table.sort(Gui.topElements, function(a, b) return a.z < b.z end) for _, win in ipairs(Gui.topElements) do win:draw() end end function Gui.update(dt) for _, win in ipairs(Gui.topElements) do win:update(dt) end end --- Destroy all elements and their children function Gui.destroy() for _, win in ipairs(Gui.topElements) do win:destroy() end Gui.topElements = {} -- Reset base scale and scale factors Gui.baseScale = nil Gui.scaleFactors = { x = 1.0, y = 1.0 } end -- Simple GUI library for LOVE2D -- Provides element and button creation, drawing, and click handling. -- ==================== -- Event System -- ==================== ---@class InputEvent ---@field type "click"|"press"|"release"|"rightclick"|"middleclick" ---@field button number -- Mouse button: 1 (left), 2 (right), 3 (middle) ---@field x number -- Mouse X position ---@field y number -- Mouse Y position ---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, cmd:boolean} ---@field clickCount number -- Number of clicks (for double/triple click detection) ---@field timestamp number -- Time when event occurred local InputEvent = {} InputEvent.__index = InputEvent ---@class InputEventProps ---@field type "click"|"press"|"release"|"rightclick"|"middleclick" ---@field button number ---@field x number ---@field y number ---@field modifiers {shift:boolean, ctrl:boolean, alt:boolean, cmd:boolean} ---@field clickCount number? ---@field timestamp number? --- Create a new input event ---@param props InputEventProps ---@return InputEvent function InputEvent.new(props) local self = setmetatable({}, InputEvent) self.type = props.type self.button = props.button self.x = props.x self.y = props.y self.modifiers = props.modifiers self.clickCount = props.clickCount or 1 self.timestamp = props.timestamp or love.timer.getTime() return self end --- Get current keyboard modifiers state ---@return {shift:boolean, ctrl:boolean, alt:boolean, cmd:boolean} local function getModifiers() return { shift = love.keyboard.isDown("lshift", "rshift"), ctrl = love.keyboard.isDown("lctrl", "rctrl"), alt = love.keyboard.isDown("lalt", "ralt"), cmd = love.keyboard.isDown("lgui", "rgui"), -- Mac Command 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? 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 return self end ---@param dt number ---@return boolean function Animation:update(dt) self.elapsed = self.elapsed + dt if self.elapsed >= self.duration then return true -- finished else return false end end ---@return table function Animation:interpolate() local t = math.min(self.elapsed / self.duration, 1) local result = {} -- 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 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 = {} --- Create or get a font from cache ---@param size number ---@return love.Font function FONT_CACHE.get(size) if not FONT_CACHE[size] then FONT_CACHE[size] = love.graphics.newFont(size) end return FONT_CACHE[size] end --- Get font for text size (cached) ---@param textSize number? ---@return love.Font function FONT_CACHE.getFont(textSize) if textSize then return FONT_CACHE.get(textSize) 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 -- ==================== ---@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: ABSOLUTE) ---@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 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 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|{component:string, state:string?}? -- Theme component to use for rendering ---@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) 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 autoScaleText boolean? -- Whether text should auto-scale with window size (default: true) ---@field positioning Positioning? -- Layout positioning mode (default: ABSOLUTE) ---@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) 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 "" -- 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 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 -- 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 --- 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" }, }, margin = { top = { value = nil, unit = "px" }, right = { value = nil, unit = "px" }, bottom = { value = nil, unit = "px" }, left = { value = nil, unit = "px" }, }, } -- 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 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 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 self.width = Units.resolve(value, unit, viewportWidth, viewportHeight, parentWidth) else -- Apply base scaling to pixel values self.width = Gui.baseScale and (widthProp * scaleX) or widthProp self.units.width = { value = widthProp, unit = "px" } end else self.autosizing.width = true self.width = self:calculateAutoWidth() 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 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 self.height = Units.resolve(value, unit, viewportWidth, viewportHeight, parentHeight) else -- Apply base scaling to pixel values self.height = Gui.baseScale and (heightProp * scaleY) or heightProp self.units.height = { value = heightProp, unit = "px" } end else self.autosizing.height = true self.height = self:calculateAutoHeight() 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 = 10 self.units.gap = { value = 10, unit = "px" } end -- Resolve padding and margin based on element's own size (after width/height are set) self.padding = Units.resolveSpacing(props.padding, self.width, self.height) self.margin = Units.resolveSpacing(props.margin, self.width, self.height) -- 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 -- 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 } else self.units.padding[side] = { value = props.padding[side], unit = "px" } end else -- Use resolved padding values from Units.resolveSpacing self.units.padding[side] = { value = self.padding[side], unit = "px" } 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 } else self.units.margin[side] = { value = props.margin[side], unit = "px" } end else -- Use resolved margin values from Units.resolveSpacing self.units.margin[side] = { value = self.margin[side], unit = "px" } 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 self.textColor = props.textColor or Color.new(0, 0, 0, 1) -- 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.ABSOLUTE 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 absolute containers default to absolute 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.ABSOLUTE self._explicitlyAbsolute = false -- Default for 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 self.textColor = props.textColor or self.parent.textColor 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 {} return self end --- Get element bounds ---@return { x:number, y:number, width:number, height:number } function Element:getBounds() return { x = self.x, y = self.y, width = self.width, height = self.height } 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.ABSOLUTE child._explicitlyAbsolute = false -- Default for 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 self.height = self:calculateAutoHeight() end if self.autosizing.width then self.width = self:calculateAutoWidth() 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 -- 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) -- Element's total height includes its padding if element.bottom then local elementTotalHeight = element.height + element.padding.top + element.padding.bottom element.y = parent.y + parent.height + parent.padding.top - element.bottom - elementTotalHeight 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) -- Element's total width includes its padding if element.right then local elementTotalWidth = element.width + element.padding.left + element.padding.right element.x = parent.x + parent.width + parent.padding.left - element.right - elementTotalWidth end end function Element:layoutChildren() if self.positioning == Positioning.ABSOLUTE then -- Absolute 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 available space (accounting for padding) local availableMainSize = 0 local availableCrossSize = 0 if self.flexDirection == FlexDirection.HORIZONTAL then availableMainSize = self.width - self.padding.left - self.padding.right availableCrossSize = self.height - self.padding.top - self.padding.bottom else availableMainSize = self.height - self.padding.top - self.padding.bottom availableCrossSize = self.width - self.padding.left - self.padding.right 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 local childMainSize = 0 if self.flexDirection == FlexDirection.HORIZONTAL then childMainSize = (child.width or 0) + child.padding.left + child.padding.right else childMainSize = (child.height or 0) + child.padding.top + child.padding.bottom end -- 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 + childMainSize > availableMainSize then -- Start a new line if #currentLine > 0 then table.insert(lines, currentLine) end currentLine = { child } currentLineSize = childMainSize else -- Add to current line table.insert(currentLine, child) currentLineSize = currentLineSize + lineSpacing + childMainSize 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 local childCrossSize = 0 if self.flexDirection == FlexDirection.HORIZONTAL then childCrossSize = (child.height or 0) + child.padding.top + child.padding.bottom else childCrossSize = (child.width or 0) + child.padding.left + child.padding.right end maxCrossSize = math.max(maxCrossSize, childCrossSize) 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) local totalChildrenSize = 0 for _, child in ipairs(line) do if self.flexDirection == FlexDirection.HORIZONTAL then local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right totalChildrenSize = totalChildrenSize + childTotalWidth else local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom totalChildrenSize = totalChildrenSize + childTotalHeight 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) child.x = self.x + self.padding.left + currentMainPos if effectiveAlign == AlignItems.FLEX_START then child.y = self.y + self.padding.top + currentCrossPos elseif effectiveAlign == AlignItems.CENTER then local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom child.y = self.y + self.padding.top + currentCrossPos + ((lineHeight - childTotalHeight) / 2) elseif effectiveAlign == AlignItems.FLEX_END then local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom child.y = self.y + self.padding.top + currentCrossPos + lineHeight - childTotalHeight elseif effectiveAlign == AlignItems.STRETCH then -- STRETCH always stretches children in cross-axis direction child.height = lineHeight - child.padding.top - child.padding.bottom child.y = self.y + self.padding.top + currentCrossPos end -- Apply positioning offsets (top, right, bottom, left) self:applyPositioningOffsets(child) -- Final position DEBUG for elements with debugId if child.debugId then print(string.format("DEBUG [%s]: Final Y position: %.2f", child.debugId, child.y)) end -- If child has children, re-layout them after position change if #child.children > 0 then child:layoutChildren() end -- Advance position by child's total width (width + padding) local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right currentMainPos = currentMainPos + childTotalWidth + itemSpacing else -- Vertical layout: main axis is Y, cross axis is X -- Position child at border box (x, y represents top-left including padding) child.y = self.y + self.padding.top + currentMainPos if effectiveAlign == AlignItems.FLEX_START then child.x = self.x + self.padding.left + currentCrossPos elseif effectiveAlign == AlignItems.CENTER then local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right child.x = self.x + self.padding.left + currentCrossPos + ((lineHeight - childTotalWidth) / 2) elseif effectiveAlign == AlignItems.FLEX_END then local childTotalWidth = (child.width or 0) + child.padding.left + child.padding.right child.x = self.x + self.padding.left + currentCrossPos + lineHeight - childTotalWidth elseif effectiveAlign == AlignItems.STRETCH then -- STRETCH always stretches children in cross-axis direction child.width = lineHeight - child.padding.left - child.padding.right child.x = self.x + self.padding.left + currentCrossPos 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 total height (height + padding) local childTotalHeight = (child.height or 0) + child.padding.top + child.padding.bottom currentMainPos = currentMainPos + childTotalHeight + itemSpacing end end -- Move to next line position currentCrossPos = currentCrossPos + lineHeight + lineSpacing end 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 end --- Draw element and its children function Element:draw() -- 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 -- LAYER 1: Draw backgroundColor first (behind everything) -- Apply opacity to all drawing operations -- (x, y) represents border box, so draw background from (x, y) 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, self.width + self.padding.left + self.padding.right, self.height + self.padding.top + self.padding.bottom, self.cornerRadius ) -- 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 then NineSlice.draw( component, atlasToUse, self.x, self.y, self.width + self.padding.left + self.padding.right, self.height + self.padding.top + self.padding.bottom, self.opacity ) else print("[FlexLove] No atlas for component: " .. self.themeComponent) 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, self.width + self.padding.left + self.padding.right, self.height + self.padding.top + self.padding.bottom, 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 + self.width + self.padding.left + self.padding.right, self.y) end if self.border.bottom then love.graphics.line( self.x, self.y + self.height + self.padding.top + self.padding.bottom, self.x + self.width + self.padding.left + self.padding.right, self.y + self.height + self.padding.top + self.padding.bottom ) end if self.border.left then love.graphics.line(self.x, self.y, self.x, self.y + self.height + self.padding.top + self.padding.bottom) end if self.border.right then love.graphics.line( self.x + self.width + self.padding.left + self.padding.right, self.y, self.x + self.width + self.padding.left + self.padding.right, self.y + self.height + self.padding.top + self.padding.bottom ) 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 -- Use cached font instead of creating new one every frame local font = FONT_CACHE.get(self.textSize) 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) local contentX = self.x + self.padding.left local contentY = self.y + self.padding.top if self.textAlign == TextAlign.START then tx = contentX ty = contentY elseif self.textAlign == TextAlign.CENTER then tx = contentX + (self.width - textWidth) / 2 ty = contentY + (self.height - textHeight) / 2 elseif self.textAlign == TextAlign.END then tx = contentX + self.width - textWidth - 10 ty = contentY + self.height - 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) 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 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, self.width + self.padding.left + self.padding.right, self.height + self.padding.top + self.padding.bottom, 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 if hasRoundedCorners and #sortedChildren > 0 then -- Use stencil to clip children to rounded rectangle local stencilFunc = RoundedRect.stencilFunction( self.x, self.y, self.width + self.padding.left + self.padding.right, self.height + self.padding.top + self.padding.bottom, self.cornerRadius ) love.graphics.stencil(stencilFunc, "replace", 1) love.graphics.setStencilTest("greater", 0) for _, child in ipairs(sortedChildren) do child:draw() end love.graphics.setStencilTest() else -- No clipping needed for _, child in ipairs(sortedChildren) do child:draw() 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 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 click detection for element with enhanced event system if self.callback or self.themeComponent then local mx, my = love.mouse.getPosition() -- Clickable area is the border box (x, y already includes padding) local bx = self.x local by = self.y local bw = self.width + self.padding.left + self.padding.right local bh = 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 and element is not disabled if self.callback and not self.disabled 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 -- Just pressed - fire press event 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 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 -- 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 self._pressed[button] = false 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 width if using viewport or percentage units (skip auto-sized) if self.units.width.unit ~= "px" and self.units.width.unit ~= "auto" then local parentWidth = self.parent and self.parent.width or newViewportWidth self.width = 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 self.width = self.units.width.value * scaleX end -- Recalculate height if using viewport or percentage units (skip auto-sized) if self.units.height.unit ~= "px" and self.units.height.unit ~= "auto" then local parentHeight = self.parent and self.parent.height or newViewportHeight self.height = 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 self.height = 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 local containerWidth = self.parent and self.parent.width or newViewportWidth local containerHeight = self.parent and self.parent.height or newViewportHeight for _, side in ipairs({ "top", "right", "bottom", "left" }) do if self.units.padding[side].unit ~= "px" then local parentSize = (side == "top" or side == "bottom") and containerHeight or containerWidth self.padding[side] = Units.resolve( self.units.padding[side].value, self.units.padding[side].unit, newViewportWidth, newViewportHeight, parentSize ) end if self.units.margin[side].unit ~= "px" then local parentSize = (side == "top" or side == "bottom") and containerHeight or containerWidth self.margin[side] = Units.resolve( self.units.margin[side].value, self.units.margin[side].unit, newViewportWidth, newViewportHeight, parentSize ) end end 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) -- 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 self.width = self:calculateAutoWidth() end if self.autosizing.height then self.height = self:calculateAutoHeight() 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 local tempFont = FONT_CACHE.get(self.textSize) local width = tempFont:getWidth(self.text) return width end local font = love.graphics.getFont() local width = font:getWidth(self.text) return width end ---@return number function Element:calculateTextHeight() if self.text == nil then return 0 end if self.textSize then local tempFont = FONT_CACHE.get(self.textSize) local height = tempFont:getHeight() return height end local font = love.graphics.getFont() local height = font:getHeight() return height end function Element:calculateAutoWidth() local width = self:calculateTextWidth() if not self.children or #self.children == 0 then return width end local totalWidth = width 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 local paddingAdjustment = (child.padding.left or 0) + (child.padding.right or 0) local childWidth = child.width or child:calculateAutoWidth() local childOffset = childWidth + paddingAdjustment totalWidth = totalWidth + childOffset participatingChildren = participatingChildren + 1 end end return totalWidth + (self.gap * participatingChildren) 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 local totalHeight = 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 local paddingAdjustment = (child.padding.top or 0) + (child.padding.bottom or 0) local childHeight = child.height or child:calculateAutoHeight() local childOffset = childHeight + paddingAdjustment totalHeight = totalHeight + childOffset participatingChildren = participatingChildren + 1 end end return totalHeight + (self.gap * participatingChildren) 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 Gui.new = Element.new Gui.Element = Element Gui.Animation = Animation Gui.Theme = Theme return { GUI = Gui, Color = Color, Theme = Theme, enums = enums }