diff --git a/modules/Element.lua b/modules/Element.lua index b79e609..34dbd49 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -24,11 +24,17 @@ local EventHandler = req("EventHandler") local ScrollManager = req("ScrollManager") local ErrorHandler = req("ErrorHandler") +-- Initialize ErrorHandler for validation utilities +utils.initializeErrorHandler(ErrorHandler) + -- Extract utilities local enums = utils.enums local FONT_CACHE = utils.FONT_CACHE local resolveTextSizePreset = utils.resolveTextSizePreset local getModifiers = utils.getModifiers +local validateEnum = utils.validateEnum +local validateRange = utils.validateRange +local validateType = utils.validateType -- Extract enum values local Positioning, FlexDirection, JustifyContent, AlignContent, AlignItems, TextAlign, AlignSelf, JustifySelf, FlexWrap = @@ -185,52 +191,6 @@ local Positioning, FlexDirection, JustifyContent, AlignContent, AlignItems, Text local Element = {} Element.__index = Element --- Validation helper functions -local function validateEnum(value, enumTable, propName, moduleName) - if value == nil then - return true - end - - for _, validValue in pairs(enumTable) do - if value == validValue then - return true - end - end - - -- Build list of valid options - local validOptions = {} - for _, v in pairs(enumTable) do - table.insert(validOptions, "'" .. v .. "'") - end - table.sort(validOptions) - - ErrorHandler.error(moduleName or "Element", string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))) -end - -local function validateRange(value, min, max, propName, moduleName) - if value == nil then - return true - end - if type(value) ~= "number" then - ErrorHandler.error(moduleName or "Element", string.format("%s must be a number, got %s", propName, type(value))) - end - if value < min or value > max then - ErrorHandler.error(moduleName or "Element", string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value))) - end - return true -end - -local function validateType(value, expectedType, propName, moduleName) - if value == nil then - return true - end - local actualType = type(value) - if actualType ~= expectedType then - ErrorHandler.error(moduleName or "Element", string.format("%s must be %s, got %s", propName, expectedType, actualType)) - end - return true -end - ---@param props ElementProps ---@return Element function Element.new(props) @@ -277,6 +237,7 @@ function Element.new(props) self._eventHandler = EventHandler.new(eventHandlerConfig, { InputEvent = InputEvent, Context = Context, + utils = utils, }) self._eventHandler:initialize(self) @@ -2265,33 +2226,9 @@ function Element:calculateTextWidth() return 0 end - if self.textSize then - local fontPath = nil - if self.fontFamily then - local themeToUse = self._themeManager:getTheme() - if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then - fontPath = themeToUse.fonts[self.fontFamily] - else - fontPath = self.fontFamily - end - elseif self.themeComponent then - fontPath = self._themeManager:getDefaultFontFamily() - end - - local tempFont = FONT_CACHE.get(self.textSize, fontPath) - local width = tempFont:getWidth(self.text) - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then - width = width * self.contentAutoSizingMultiplier.width - end - return width - end - - local font = love.graphics.getFont() + local font = utils.getFont(self.textSize, self.fontFamily, self.themeComponent, self._themeManager) local width = font:getWidth(self.text) - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then - width = width * self.contentAutoSizingMultiplier.width - end - return width + return utils.applyContentMultiplier(width, self.contentAutoSizingMultiplier, "width") end ---@return number @@ -2300,24 +2237,7 @@ function Element:calculateTextHeight() return 0 end - local font - if self.textSize then - local fontPath = nil - if self.fontFamily then - local themeToUse = self._themeManager:getTheme() - if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then - fontPath = themeToUse.fonts[self.fontFamily] - else - fontPath = self.fontFamily - end - elseif self.themeComponent then - fontPath = self._themeManager:getDefaultFontFamily() - end - font = FONT_CACHE.get(self.textSize, fontPath) - else - font = love.graphics.getFont() - end - + local font = utils.getFont(self.textSize, self.fontFamily, self.themeComponent, self._themeManager) local height = font:getHeight() if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then @@ -2333,11 +2253,7 @@ function Element:calculateTextHeight() end end - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.height then - height = height * self.contentAutoSizingMultiplier.height - end - - return height + return utils.applyContentMultiplier(height, self.contentAutoSizingMultiplier, "height") end function Element:calculateAutoWidth() diff --git a/modules/EventHandler.lua b/modules/EventHandler.lua index 45e004e..a1c9cf1 100644 --- a/modules/EventHandler.lua +++ b/modules/EventHandler.lua @@ -1,12 +1,3 @@ -local function getModifiers() - return { - shift = love.keyboard.isDown("lshift") or love.keyboard.isDown("rshift"), - ctrl = love.keyboard.isDown("lctrl") or love.keyboard.isDown("rctrl"), - alt = love.keyboard.isDown("lalt") or love.keyboard.isDown("ralt"), - meta = love.keyboard.isDown("lgui") or love.keyboard.isDown("rgui"), - } -end - ---@class EventHandler ---@field onEvent fun(element:Element, event:InputEvent)? ---@field _pressed table @@ -23,12 +14,13 @@ end ---@field _scrollbarPressHandled boolean ---@field _InputEvent table ---@field _Context table +---@field _utils table local EventHandler = {} EventHandler.__index = EventHandler --- Create a new EventHandler instance ---@param config table Configuration options ----@param deps table Dependencies {InputEvent, Context} +---@param deps table Dependencies {InputEvent, Context, utils} ---@return EventHandler function EventHandler.new(config, deps) config = config or {} @@ -37,6 +29,7 @@ function EventHandler.new(config, deps) self._InputEvent = deps.InputEvent self._Context = deps.Context + self._utils = deps.utils self.onEvent = config.onEvent @@ -222,7 +215,7 @@ function EventHandler:_handleMousePress(mx, my, button) -- Fire press event if self.onEvent then - local modifiers = getModifiers() + local modifiers = self._utils.getModifiers() local pressEvent = self._InputEvent.new({ type = "press", button = button, @@ -267,7 +260,7 @@ function EventHandler:_handleMouseDrag(mx, my, button, isHovering) if lastX ~= mx or lastY ~= my then -- Mouse has moved - fire drag event only if still hovering if self.onEvent and isHovering then - local modifiers = getModifiers() + local modifiers = self._utils.getModifiers() local dx = mx - self._dragStartX[button] local dy = my - self._dragStartY[button] @@ -307,7 +300,7 @@ function EventHandler:_handleMouseRelease(mx, my, button) local element = self._element local currentTime = love.timer.getTime() - local modifiers = getModifiers() + local modifiers = self._utils.getModifiers() -- Determine click count (double-click detection) local clickCount = 1 @@ -412,7 +405,7 @@ function EventHandler:processTouchEvents() button = 1, x = tx, y = ty, - modifiers = getModifiers(), + modifiers = self._utils.getModifiers(), clickCount = 1, }) self.onEvent(element, touchEvent) diff --git a/modules/ImageCache.lua b/modules/ImageCache.lua index 2b273d3..dbe594d 100644 --- a/modules/ImageCache.lua +++ b/modules/ImageCache.lua @@ -1,18 +1,15 @@ +local modulePath = (...):match("(.-)[^%.]+$") +local function req(name) + return require(modulePath .. name) +end + +local utils = req("utils") + ---@class ImageCache ---@field _cache table local ImageCache = {} ImageCache._cache = {} ---- Normalize a file path for consistent cache keys ----@param path string -- File path to normalize ----@return string -- Normalized path -local function normalizePath(path) - path = path:match("^%s*(.-)%s*$") - path = path:gsub("\\", "/") - path = path:gsub("/+", "/") - return path -end - --- Load an image from file path with caching --- Returns cached image if already loaded, otherwise loads and caches it ---@param imagePath string -- Path to image file @@ -24,7 +21,7 @@ function ImageCache.load(imagePath, loadImageData) return nil, "Invalid image path: path must be a non-empty string" end - local normalizedPath = normalizePath(imagePath) + local normalizedPath = utils.normalizePath(imagePath) if ImageCache._cache[normalizedPath] then return ImageCache._cache[normalizedPath].image, nil @@ -61,7 +58,7 @@ function ImageCache.get(imagePath) return nil end - local normalizedPath = normalizePath(imagePath) + local normalizedPath = utils.normalizePath(imagePath) local cached = ImageCache._cache[normalizedPath] return cached and cached.image or nil end @@ -74,7 +71,7 @@ function ImageCache.getImageData(imagePath) return nil end - local normalizedPath = normalizePath(imagePath) + local normalizedPath = utils.normalizePath(imagePath) local cached = ImageCache._cache[normalizedPath] return cached and cached.imageData or nil end @@ -87,7 +84,7 @@ function ImageCache.remove(imagePath) return false end - local normalizedPath = normalizePath(imagePath) + local normalizedPath = utils.normalizePath(imagePath) if ImageCache._cache[normalizedPath] then local cached = ImageCache._cache[normalizedPath] if cached.image then diff --git a/modules/Renderer.lua b/modules/Renderer.lua index 5463dec..6f449e4 100644 --- a/modules/Renderer.lua +++ b/modules/Renderer.lua @@ -352,18 +352,7 @@ end ---@param element table Reference to the parent Element instance ---@return love.Font function Renderer:getFont(element) - -- Get font path from theme or element - local fontPath = nil - if element.fontFamily then - local themeToUse = element._themeManager:getTheme() - if themeToUse and themeToUse.fonts and themeToUse.fonts[element.fontFamily] then - fontPath = themeToUse.fonts[element.fontFamily] - else - fontPath = element.fontFamily - end - end - - return self._FONT_CACHE.getFont(element.textSize, fontPath) + return self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager) end --- Wrap a line of text based on element's textWrap mode @@ -596,27 +585,8 @@ function Renderer:drawText(element) local origFont = love.graphics.getFont() if element.textSize then - -- Resolve font path from font family - local fontPath = nil - if element.fontFamily then - -- Check if fontFamily is a theme font name - local themeToUse = element.theme and self._Theme.get(element.theme) or self._Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts[element.fontFamily] then - fontPath = themeToUse.fonts[element.fontFamily] - else - -- Treat as direct path to font file - fontPath = element.fontFamily - end - elseif element.themeComponent then - -- If using themeComponent but no fontFamily specified, check for default font in theme - local themeToUse = element.theme and self._Theme.get(element.theme) or self._Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts.default then - fontPath = themeToUse.fonts.default - end - end - -- Use cached font instead of creating new one every frame - local font = self._FONT_CACHE.get(element.textSize, fontPath) + local font = self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager) love.graphics.setFont(font) end local font = love.graphics.getFont() @@ -770,16 +740,7 @@ function Renderer:drawText(element) -- Set up font for cursor rendering local origFont = love.graphics.getFont() if element.textSize then - local fontPath = nil - if element.fontFamily then - local themeToUse = element.theme and self._Theme.get(element.theme) or self._Theme.getActive() - if themeToUse and themeToUse.fonts and themeToUse.fonts[element.fontFamily] then - fontPath = themeToUse.fonts[element.fontFamily] - else - fontPath = element.fontFamily - end - end - local font = self._FONT_CACHE.get(element.textSize, fontPath) + local font = self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager) love.graphics.setFont(font) end @@ -830,10 +791,12 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims) local thumbColor = element.scrollbarColor if element._scrollbarDragging and element._hoveredScrollbar == "vertical" then -- Active state: brighter - thumbColor = self._Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a) + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4) + thumbColor = self._Color.new(r, g, b, a) elseif element._scrollbarHoveredVertical then -- Hover state: slightly brighter - thumbColor = self._Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a) + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2) + thumbColor = self._Color.new(r, g, b, a) end -- Draw track @@ -857,10 +820,12 @@ function Renderer:drawScrollbars(element, x, y, w, h, dims) local thumbColor = element.scrollbarColor if element._scrollbarDragging and element._hoveredScrollbar == "horizontal" then -- Active state: brighter - thumbColor = self._Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a) + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.4) + thumbColor = self._Color.new(r, g, b, a) elseif element._scrollbarHoveredHorizontal then -- Hover state: slightly brighter - thumbColor = self._Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a) + local r, g, b, a = self._utils.brightenColor(thumbColor.r, thumbColor.g, thumbColor.b, thumbColor.a, 1.2) + thumbColor = self._Color.new(r, g, b, a) end -- Draw track diff --git a/modules/ScrollManager.lua b/modules/ScrollManager.lua index 7920fff..7f034d3 100644 --- a/modules/ScrollManager.lua +++ b/modules/ScrollManager.lua @@ -25,19 +25,21 @@ ---@field _scrollbarDragOffset number -- Offset from thumb top when drag started ---@field _scrollbarPressHandled boolean -- Track if scrollbar press was handled this frame ---@field _Color table +---@field _utils table local ScrollManager = {} ScrollManager.__index = ScrollManager --- Create a new ScrollManager instance ---@param config table Configuration options ----@param deps table Dependencies {Color: Color module} +---@param deps table Dependencies {Color: Color module, utils: utils module} ---@return ScrollManager function ScrollManager.new(config, deps) local Color = deps.Color local self = setmetatable({}, ScrollManager) - -- Store dependency for instance methods + -- Store dependencies for instance methods self._Color = Color + self._utils = deps.utils -- Configuration self.overflow = config.overflow or "hidden" @@ -53,20 +55,7 @@ function ScrollManager.new(config, deps) self.scrollSpeed = config.scrollSpeed or 20 -- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean} - if config.hideScrollbars ~= nil then - if type(config.hideScrollbars) == "boolean" then - self.hideScrollbars = { vertical = config.hideScrollbars, horizontal = config.hideScrollbars } - elseif type(config.hideScrollbars) == "table" then - self.hideScrollbars = { - vertical = config.hideScrollbars.vertical ~= nil and config.hideScrollbars.vertical or false, - horizontal = config.hideScrollbars.horizontal ~= nil and config.hideScrollbars.horizontal or false, - } - else - self.hideScrollbars = { vertical = false, horizontal = false } - end - else - self.hideScrollbars = { vertical = false, horizontal = false } - end + self.hideScrollbars = self._utils.normalizeBooleanTable(config.hideScrollbars, false) -- Internal overflow state self._overflowX = false @@ -163,8 +152,8 @@ function ScrollManager:detectOverflow() self._maxScrollY = math.max(0, self._contentHeight - containerHeight) -- Clamp current scroll position to new bounds - self._scrollX = math.max(0, math.min(self._scrollX, self._maxScrollX)) - self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY)) + self._scrollX = self._utils.clamp(self._scrollX, 0, self._maxScrollX) + self._scrollY = self._utils.clamp(self._scrollY, 0, self._maxScrollY) end --- Set scroll position with bounds clamping @@ -172,10 +161,10 @@ end ---@param y number? -- Y scroll position (nil to keep current) function ScrollManager:setScroll(x, y) if x ~= nil then - self._scrollX = math.max(0, math.min(x, self._maxScrollX)) + self._scrollX = self._utils.clamp(x, 0, self._maxScrollX) end if y ~= nil then - self._scrollY = math.max(0, math.min(y, self._maxScrollY)) + self._scrollY = self._utils.clamp(y, 0, self._maxScrollY) end end @@ -190,10 +179,10 @@ end ---@param dy number? -- Y delta (nil for no change) function ScrollManager:scrollBy(dx, dy) if dx then - self._scrollX = math.max(0, math.min(self._scrollX + dx, self._maxScrollX)) + self._scrollX = self._utils.clamp(self._scrollX + dx, 0, self._maxScrollX) end if dy then - self._scrollY = math.max(0, math.min(self._scrollY + dy, self._maxScrollY)) + self._scrollY = self._utils.clamp(self._scrollY + dy, 0, self._maxScrollY) end end @@ -454,7 +443,7 @@ function ScrollManager:handleMouseMove(mouseX, mouseY) -- Calculate new thumb position local newThumbY = mouseY - self._scrollbarDragOffset - trackY - newThumbY = math.max(0, math.min(newThumbY, trackH - thumbH)) + newThumbY = self._utils.clamp(newThumbY, 0, trackH - thumbH) -- Convert thumb position to scroll position local scrollRatio = (trackH - thumbH) > 0 and (newThumbY / (trackH - thumbH)) or 0 @@ -470,7 +459,7 @@ function ScrollManager:handleMouseMove(mouseX, mouseY) -- Calculate new thumb position local newThumbX = mouseX - self._scrollbarDragOffset - trackX - newThumbX = math.max(0, math.min(newThumbX, trackW - thumbW)) + newThumbX = self._utils.clamp(newThumbX, 0, trackW - thumbW) -- Convert thumb position to scroll position local scrollRatio = (trackW - thumbW) > 0 and (newThumbX / (trackW - thumbW)) or 0 @@ -519,7 +508,7 @@ function ScrollManager:_scrollToTrackPosition(mouseX, mouseY, component) -- Calculate target thumb position (centered on click) local targetThumbY = mouseY - trackY - (thumbH / 2) - targetThumbY = math.max(0, math.min(targetThumbY, trackH - thumbH)) + targetThumbY = self._utils.clamp(targetThumbY, 0, trackH - thumbH) -- Convert to scroll position local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0 @@ -534,7 +523,7 @@ function ScrollManager:_scrollToTrackPosition(mouseX, mouseY, component) -- Calculate target thumb position (centered on click) local targetThumbX = mouseX - trackX - (thumbW / 2) - targetThumbX = math.max(0, math.min(targetThumbX, trackW - thumbW)) + targetThumbX = self._utils.clamp(targetThumbX, 0, trackW - thumbW) -- Convert to scroll position local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0 diff --git a/modules/Theme.lua b/modules/Theme.lua index c86cb30..9dc9fe6 100644 --- a/modules/Theme.lua +++ b/modules/Theme.lua @@ -5,6 +5,7 @@ end local NinePatchParser = req("NinePatchParser") local Color = req("Color") +local utils = req("utils") --- Standardized error message formatter ---@param module string -- Module name (e.g., "Color", "Theme", "Units") @@ -52,47 +53,6 @@ end -- Store the base paths when module loads local FLEXLOVE_BASE_PATH, FLEXLOVE_FILESYSTEM_PATH = getFlexLoveBasePath() ---- Helper function to resolve image paths relative to FlexLove ----@param imagePath string ----@return string -local function resolveImagePath(imagePath) - -- If path is already absolute or starts with known LÖVE paths, use as-is - if imagePath:match("^/") or imagePath:match("^[A-Z]:") then - return imagePath - end - - -- Otherwise, make it relative to FlexLove's location - return FLEXLOVE_FILESYSTEM_PATH .. "/" .. imagePath -end - ---- Safely load an image with error handling ---- Returns both Image and ImageData to avoid deprecated getData() API ----@param imagePath string ----@return love.Image?, love.ImageData?, string? -- Returns image, imageData, or nil with error message -local function safeLoadImage(imagePath) - local success, imageData = pcall(function() - return love.image.newImageData(imagePath) - end) - - if not success then - local errorMsg = string.format("[FlexLove] Failed to load image data: %s - %s", imagePath, tostring(imageData)) - print(errorMsg) - return nil, nil, errorMsg - end - - local imageSuccess, image = pcall(function() - return love.graphics.newImage(imageData) - end) - - if imageSuccess then - return image, imageData, nil - else - local errorMsg = string.format("[FlexLove] Failed to create image: %s - %s", imagePath, tostring(image)) - print(errorMsg) - return nil, nil, errorMsg - end -end - --- Validate theme definition structure ---@param definition ThemeDefinition ---@return boolean, string? -- Returns true if valid, or false with error message @@ -184,8 +144,8 @@ function Theme.new(definition) -- Load global atlas if it's a string path if definition.atlas then if type(definition.atlas) == "string" then - local resolvedPath = resolveImagePath(definition.atlas) - local image, imageData, loaderr = safeLoadImage(resolvedPath) + local resolvedPath = utils.resolveImagePath(definition.atlas) + local image, imageData, loaderr = utils.safeLoadImage(resolvedPath) if image then self.atlas = image self.atlasData = imageData @@ -234,7 +194,7 @@ function Theme.new(definition) -- Helper function to load atlas with 9-patch support local function loadAtlasWithNinePatch(comp, atlasPath, errorContext) ---@diagnostic disable-next-line - local resolvedPath = resolveImagePath(atlasPath) + local resolvedPath = utils.resolveImagePath(atlasPath) ---@diagnostic disable-next-line local is9Patch = not comp.insets and atlasPath:match("%.9%.png$") @@ -248,7 +208,7 @@ function Theme.new(definition) end end - local image, imageData, loaderr = safeLoadImage(resolvedPath) + local image, imageData, loaderr = utils.safeLoadImage(resolvedPath) if image then -- Strip guide border for 9-patch images if is9Patch and imageData then diff --git a/modules/utils.lua b/modules/utils.lua index 7f7502d..3570e97 100644 --- a/modules/utils.lua +++ b/modules/utils.lua @@ -186,10 +186,268 @@ function FONT_CACHE.getFont(textSize, fontPath) end end +-- Font resolution utilities + +--- Resolve font path from fontFamily and theme +---@param fontFamily string? Font family name or direct path +---@param themeComponent string? Theme component name +---@param themeManager table? ThemeManager instance +---@return string? Resolved font path or nil +local function resolveFontPath(fontFamily, themeComponent, themeManager) + if fontFamily then + -- Check if fontFamily is a theme font name + local themeToUse = themeManager and themeManager:getTheme() + if themeToUse and themeToUse.fonts and themeToUse.fonts[fontFamily] then + return themeToUse.fonts[fontFamily] + else + -- Treat as direct path to font file + return fontFamily + end + elseif themeComponent and themeManager then + -- If using themeComponent but no fontFamily specified, check for default font in theme + return themeManager:getDefaultFontFamily() + end + return nil +end + +--- Get font for element (resolves from theme or fontFamily) +---@param textSize number? Text size in pixels +---@param fontFamily string? Font family name or direct path +---@param themeComponent string? Theme component name +---@param themeManager table? ThemeManager instance +---@return love.Font +local function getFont(textSize, fontFamily, themeComponent, themeManager) + local fontPath = resolveFontPath(fontFamily, themeComponent, themeManager) + return FONT_CACHE.getFont(textSize, fontPath) +end + +--- Apply content auto-sizing multiplier to a dimension +---@param value number The dimension value +---@param multiplier table? The contentAutoSizingMultiplier table {width:number?, height:number?} +---@param axis "width"|"height" Which axis to apply +---@return number The multiplied value +local function applyContentMultiplier(value, multiplier, axis) + if multiplier and multiplier[axis] then + return value * multiplier[axis] + end + return value +end + +-- Validation utilities +local ErrorHandler = nil + +--- Initialize ErrorHandler dependency for validation utilities +---@param errorHandler table The ErrorHandler module +local function initializeErrorHandler(errorHandler) + ErrorHandler = errorHandler +end + +--- Validate that a value is in an enum table +---@param value any Value to validate +---@param enumTable table Enum table with valid values +---@param propName string Property name for error messages +---@param moduleName string? Module name for error messages (default: "Element") +---@return boolean True if valid +local function validateEnum(value, enumTable, propName, moduleName) + if value == nil then + return true + end + + for _, validValue in pairs(enumTable) do + if value == validValue then + return true + end + end + + -- Build list of valid options + local validOptions = {} + for _, v in pairs(enumTable) do + table.insert(validOptions, "'" .. v .. "'") + end + table.sort(validOptions) + + if ErrorHandler then + ErrorHandler.error(moduleName or "Element", string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))) + else + error(string.format("%s must be one of: %s. Got: '%s'", propName, table.concat(validOptions, ", "), tostring(value))) + end +end + +--- Validate that a numeric value is within a range +---@param value any Value to validate +---@param min number Minimum allowed value +---@param max number Maximum allowed value +---@param propName string Property name for error messages +---@param moduleName string? Module name for error messages (default: "Element") +---@return boolean True if valid +local function validateRange(value, min, max, propName, moduleName) + if value == nil then + return true + end + if type(value) ~= "number" then + if ErrorHandler then + ErrorHandler.error(moduleName or "Element", string.format("%s must be a number, got %s", propName, type(value))) + else + error(string.format("%s must be a number, got %s", propName, type(value))) + end + end + if value < min or value > max then + if ErrorHandler then + ErrorHandler.error(moduleName or "Element", string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value))) + else + error(string.format("%s must be between %s and %s, got %s", propName, tostring(min), tostring(max), tostring(value))) + end + end + return true +end + +--- Validate that a value is of the expected type +---@param value any Value to validate +---@param expectedType string Expected type name +---@param propName string Property name for error messages +---@param moduleName string? Module name for error messages (default: "Element") +---@return boolean True if valid +local function validateType(value, expectedType, propName, moduleName) + if value == nil then + return true + end + local actualType = type(value) + if actualType ~= expectedType then + if ErrorHandler then + ErrorHandler.error(moduleName or "Element", string.format("%s must be %s, got %s", propName, expectedType, actualType)) + else + error(string.format("%s must be %s, got %s", propName, expectedType, actualType)) + end + end + return true +end + +-- Math utilities + +--- Clamp a value between min and max +---@param value number Value to clamp +---@param min number Minimum value +---@param max number Maximum value +---@return number Clamped value +local function clamp(value, min, max) + return math.max(min, math.min(value, max)) +end + +--- Linear interpolation between two values +---@param a number Start value +---@param b number End value +---@param t number Interpolation factor (0-1) +---@return number Interpolated value +local function lerp(a, b, t) + return a + (b - a) * t +end + +--- Round a number to the nearest integer +---@param value number Value to round +---@return number Rounded value +local function round(value) + return math.floor(value + 0.5) +end + +-- Path and Image utilities + +--- Normalize a file path for consistent cache keys +---@param path string File path to normalize +---@return string Normalized path +local function normalizePath(path) + path = path:match("^%s*(.-)%s*$") + path = path:gsub("\\", "/") + path = path:gsub("/+", "/") + return path +end + +--- Safely load an image with error handling +--- Returns both Image and ImageData to avoid deprecated getData() API +---@param imagePath string Path to image file +---@return love.Image?, love.ImageData?, string? Returns image, imageData, or nil with error message +local function safeLoadImage(imagePath) + local success, imageData = pcall(function() + return love.image.newImageData(imagePath) + end) + + if not success then + local errorMsg = string.format("[FlexLove] Failed to load image data: %s - %s", imagePath, tostring(imageData)) + print(errorMsg) + return nil, nil, errorMsg + end + + local imageSuccess, image = pcall(function() + return love.graphics.newImage(imageData) + end) + + if imageSuccess then + return image, imageData, nil + else + local errorMsg = string.format("[FlexLove] Failed to create image: %s - %s", imagePath, tostring(image)) + print(errorMsg) + return nil, nil, errorMsg + end +end + +-- Color manipulation utilities + +--- Brighten a color by a factor +---@param r number Red component (0-1) +---@param g number Green component (0-1) +---@param b number Blue component (0-1) +---@param a number Alpha component (0-1) +---@param factor number Brightness factor (e.g., 1.2 for 20% brighter) +---@return number, number, number, number Brightened color components +local function brightenColor(r, g, b, a, factor) + return math.min(1, r * factor), math.min(1, g * factor), math.min(1, b * factor), a +end + +-- Property normalization utilities + +--- Normalize a boolean or table property with vertical/horizontal fields +---@param value boolean|table|nil Input value (boolean applies to both, table for individual control) +---@param defaultValue boolean Default value if nil (default: false) +---@return table Normalized table with vertical and horizontal fields +local function normalizeBooleanTable(value, defaultValue) + defaultValue = defaultValue or false + + if value == nil then + return { vertical = defaultValue, horizontal = defaultValue } + end + + if type(value) == "boolean" then + return { vertical = value, horizontal = value } + end + + if type(value) == "table" then + return { + vertical = value.vertical ~= nil and value.vertical or defaultValue, + horizontal = value.horizontal ~= nil and value.horizontal or defaultValue, + } + end + + return { vertical = defaultValue, horizontal = defaultValue } +end + return { enums = enums, FONT_CACHE = FONT_CACHE, resolveTextSizePreset = resolveTextSizePreset, getModifiers = getModifiers, TEXT_SIZE_PRESETS = TEXT_SIZE_PRESETS, + initializeErrorHandler = initializeErrorHandler, + validateEnum = validateEnum, + validateRange = validateRange, + validateType = validateType, + clamp = clamp, + lerp = lerp, + round = round, + normalizePath = normalizePath, + safeLoadImage = safeLoadImage, + brightenColor = brightenColor, + resolveImagePath = resolveImagePath, + normalizeBooleanTable = normalizeBooleanTable, + resolveFontPath = resolveFontPath, + getFont = getFont, + applyContentMultiplier = applyContentMultiplier, } diff --git a/release b/release deleted file mode 100755 index d5e45cf..0000000 --- a/release +++ /dev/null @@ -1,75 +0,0 @@ -#!/bin/bash - -# Release script for FlexLove -# Usage: ./release -# Example: ./release 0.2.0 - -set -e - -if [ -z "$1" ]; then - echo "Error: Version number required" - echo "Usage: ./release " - echo "Example: ./release 0.2.0" - exit 1 -fi - -VERSION="$1" - -# Validate version format (basic semver check) -if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Error: Invalid version format. Use semantic versioning (e.g., 0.2.0)" - exit 1 -fi - -echo "Preparing release v$VERSION..." - -# Update version in README.md -echo "Updating README.md..." -sed -i.bak "s/^# FlexLöve v[0-9]\+\.[0-9]\+\.[0-9]\+/# FlexLöve v$VERSION/" README.md -rm README.md.bak - -# Update version in FlexLove.lua -echo "Updating FlexLove.lua..." -sed -i.bak "s/FlexLove\._VERSION = \"[0-9]\+\.[0-9]\+\.[0-9]\+\"/FlexLove._VERSION = \"$VERSION\"/" FlexLove.lua -rm FlexLove.lua.bak - -# Show the changes -echo "" -echo "Changes made:" -git diff README.md FlexLove.lua - -# Ask for confirmation -echo "" -read -p "Do you want to commit these changes and create a release? (y/n) " -n 1 -r -echo -if [[ ! $REPLY =~ ^[Yy]$ ]]; then - echo "Release cancelled. Restoring original files..." - git checkout README.md FlexLove.lua - exit 1 -fi - -# Commit the changes -echo "Committing version bump..." -git add README.md FlexLove.lua -git commit -m "Bump version to v$VERSION" - -# Create git tag -echo "Creating git tag v$VERSION..." -git tag -a "v$VERSION" -m "Release v$VERSION" - -# Push changes and tags -echo "Pushing to remote..." -git push origin main -git push origin "v$VERSION" - -# Create GitHub release -echo "Creating GitHub release..." -gh release create "v$VERSION" \ - --title "v$VERSION" \ - --generate-notes - -echo "" -echo "✅ Release v$VERSION completed successfully!" -echo " - README.md and FlexLove.lua updated" -echo " - Git tag v$VERSION created" -echo " - GitHub release created"