image and animation progress

This commit is contained in:
Michael Freno
2025-11-18 10:42:20 -05:00
parent 92ed25cac5
commit 9f147c1d84
22 changed files with 2747 additions and 502 deletions

View File

@@ -46,22 +46,27 @@ local Easing = {
}
---@class AnimationProps
---@field duration number Duration in seconds
---@field start {width?:number, height?:number, opacity?:number} Starting values
---@field final {width?:number, height?:number, opacity?:number} Final values
---@field start table Starting values (can contain: width, height, opacity, x, y, gap, imageOpacity, backgroundColor, borderColor, textColor, padding, margin, cornerRadius, etc.)
---@field final table Final values (same properties as start)
---@field easing string? Easing function name (default: "linear")
---@field transform table? Additional transform properties
---@field transition table? Transition properties
---@field onStart function? Called when animation starts: (animation, element)
---@field onUpdate function? Called each frame: (animation, element, progress)
---@field onComplete function? Called when animation completes: (animation, element)
---@field onCancel function? Called when animation is cancelled: (animation, element)
---@class Animation
---@field duration number Duration in seconds
---@field start {width?:number, height?:number, opacity?:number} Starting values
---@field final {width?:number, height?:number, opacity?:number} Final values
---@field start table Starting values
---@field final table Final values
---@field elapsed number Elapsed time in seconds
---@field easing EasingFunction Easing function
---@field transform table? Additional transform properties
---@field transition table? Transition properties
---@field _cachedResult table Cached interpolation result
---@field _resultDirty boolean Whether cached result needs recalculation
---@field _Color table? Reference to Color module (for lerp)
local Animation = {}
Animation.__index = Animation
@@ -94,6 +99,19 @@ function Animation.new(props)
self.transition = props.transition
self.elapsed = 0
-- Lifecycle callbacks
self.onStart = props.onStart
self.onUpdate = props.onUpdate
self.onComplete = props.onComplete
self.onCancel = props.onCancel
self._hasStarted = false
-- Control state
self._paused = false
self._reversed = false
self._speed = 1.0
self._state = "pending" -- "pending", "playing", "paused", "completed", "cancelled"
-- Validate and set easing function
local easingName = props.easing or "linear"
if type(easingName) == "string" then
@@ -113,24 +131,140 @@ end
---Update the animation with delta time
---@param dt number Delta time in seconds
---@param element table? Optional element reference for callbacks
---@return boolean completed True if animation is complete
function Animation:update(dt)
function Animation:update(dt, element)
-- Sanitize dt
if type(dt) ~= "number" or dt < 0 or dt ~= dt or dt == math.huge then
dt = 0
end
self.elapsed = self.elapsed + dt
self._resultDirty = true
if self.elapsed >= self.duration then
return true
else
-- Don't update if paused
if self._paused then
return false
end
-- Call onStart on first update
if not self._hasStarted then
self._hasStarted = true
self._state = "playing"
if self.onStart and type(self.onStart) == "function" then
local success, err = pcall(self.onStart, self, element)
if not success then
-- Log error but don't crash
print(string.format("[Animation] onStart error: %s", tostring(err)))
end
end
end
-- Apply speed multiplier
dt = dt * self._speed
-- Update elapsed time (reversed if needed)
if self._reversed then
self.elapsed = self.elapsed - dt
if self.elapsed <= 0 then
self.elapsed = 0
self._state = "completed"
self._resultDirty = true
-- Call onComplete callback
if self.onComplete and type(self.onComplete) == "function" then
local success, err = pcall(self.onComplete, self, element)
if not success then
print(string.format("[Animation] onComplete error: %s", tostring(err)))
end
end
return true
end
else
self.elapsed = self.elapsed + dt
if self.elapsed >= self.duration then
self.elapsed = self.duration
self._state = "completed"
self._resultDirty = true
-- Call onComplete callback
if self.onComplete and type(self.onComplete) == "function" then
local success, err = pcall(self.onComplete, self, element)
if not success then
print(string.format("[Animation] onComplete error: %s", tostring(err)))
end
end
return true
end
end
self._resultDirty = true
-- Call onUpdate callback
if self.onUpdate and type(self.onUpdate) == "function" then
local progress = self.elapsed / self.duration
local success, err = pcall(self.onUpdate, self, element, progress)
if not success then
print(string.format("[Animation] onUpdate error: %s", tostring(err)))
end
end
return false
end
--- Helper function to interpolate numeric values
---@param startValue number Starting value
---@param finalValue number Final value
---@param easedT number Eased time (0-1)
---@return number interpolated Interpolated value
local function lerpNumber(startValue, finalValue, easedT)
return startValue * (1 - easedT) + finalValue * easedT
end
--- Helper function to interpolate Color values
---@param startColor any Starting color (Color instance or parseable color)
---@param finalColor any Final color (Color instance or parseable color)
---@param easedT number Eased time (0-1)
---@param ColorModule table Color module reference
---@return any interpolated Interpolated Color instance
local function lerpColor(startColor, finalColor, easedT, ColorModule)
if not ColorModule then
return nil
end
-- Parse colors if needed
local colorA = ColorModule.parse(startColor)
local colorB = ColorModule.parse(finalColor)
return ColorModule.lerp(colorA, colorB, easedT)
end
--- Helper function to interpolate table values (padding, margin, cornerRadius)
---@param startTable table Starting table
---@param finalTable table Final table
---@param easedT number Eased time (0-1)
---@return table interpolated Interpolated table
local function lerpTable(startTable, finalTable, easedT)
local result = {}
-- Iterate through all keys in both tables
local keys = {}
for k in pairs(startTable) do keys[k] = true end
for k in pairs(finalTable) do keys[k] = true end
for key in pairs(keys) do
local startVal = startTable[key]
local finalVal = finalTable[key]
if type(startVal) == "number" and type(finalVal) == "number" then
result[key] = lerpNumber(startVal, finalVal, easedT)
elseif startVal ~= nil then
result[key] = startVal
else
result[key] = finalVal
end
end
return result
end
---Interpolate animation values at current time
---@return table result Interpolated values {width?, height?, opacity?, ...}
---@return table result Interpolated values {width?, height?, opacity?, x?, y?, backgroundColor?, ...}
function Animation:interpolate()
-- Return cached result if not dirty (avoids recalculation)
if not self._resultDirty then
@@ -146,27 +280,68 @@ function Animation:interpolate()
end
local result = self._cachedResult -- Reuse existing table
result.width = nil
result.height = nil
result.opacity = nil
-- Interpolate width if both start and final are valid numbers
if type(self.start.width) == "number" and type(self.final.width) == "number" then
result.width = self.start.width * (1 - easedT) + self.final.width * easedT
-- Clear previous results
for k in pairs(result) do
result[k] = nil
end
-- Define properties that should be animated as numbers
local numericProperties = {
"width", "height", "opacity", "x", "y",
"gap", "imageOpacity", "scrollbarWidth",
"borderWidth", "fontSize", "lineHeight"
}
-- Define properties that should be animated as Colors
local colorProperties = {
"backgroundColor", "borderColor", "textColor",
"scrollbarColor", "scrollbarBackgroundColor", "imageTint"
}
-- Define properties that should be animated as tables
local tableProperties = {
"padding", "margin", "cornerRadius"
}
-- Interpolate numeric properties
for _, prop in ipairs(numericProperties) do
local startVal = self.start[prop]
local finalVal = self.final[prop]
if type(startVal) == "number" and type(finalVal) == "number" then
result[prop] = lerpNumber(startVal, finalVal, easedT)
end
end
-- Interpolate color properties (if Color module is available)
if self._Color then
for _, prop in ipairs(colorProperties) do
local startVal = self.start[prop]
local finalVal = self.final[prop]
if startVal ~= nil and finalVal ~= nil then
result[prop] = lerpColor(startVal, finalVal, easedT, self._Color)
end
end
end
-- Interpolate table properties
for _, prop in ipairs(tableProperties) do
local startVal = self.start[prop]
local finalVal = self.final[prop]
if type(startVal) == "table" and type(finalVal) == "table" then
result[prop] = lerpTable(startVal, finalVal, easedT)
end
end
-- Interpolate height if both start and final are valid numbers
if type(self.start.height) == "number" and type(self.final.height) == "number" then
result.height = self.start.height * (1 - easedT) + self.final.height * easedT
-- Interpolate transform property (if Transform module is available)
if self._Transform and self.start.transform and self.final.transform then
result.transform = self._Transform.lerp(self.start.transform, self.final.transform, easedT)
end
-- Interpolate opacity if both start and final are valid numbers
if type(self.start.opacity) == "number" and type(self.final.opacity) == "number" then
result.opacity = self.start.opacity * (1 - easedT) + self.final.opacity * easedT
end
-- Copy transform properties
-- Copy transform properties (legacy support)
if self.transform and type(self.transform) == "table" then
for key, value in pairs(self.transform) do
result[key] = value
@@ -186,6 +361,109 @@ function Animation:apply(element)
element.animation = self
end
--- Set Color module reference for color interpolation
---@param ColorModule table Color module
function Animation:setColorModule(ColorModule)
self._Color = ColorModule
end
--- Set Transform module reference for transform interpolation
---@param TransformModule table Transform module
function Animation:setTransformModule(TransformModule)
self._Transform = TransformModule
end
---Pause the animation
function Animation:pause()
if self._state == "playing" or self._state == "pending" then
self._paused = true
self._state = "paused"
end
end
---Resume the animation
function Animation:resume()
if self._state == "paused" then
self._paused = false
self._state = "playing"
end
end
---Check if animation is paused
---@return boolean paused
function Animation:isPaused()
return self._paused
end
---Reverse the animation direction
function Animation:reverse()
self._reversed = not self._reversed
end
---Check if animation is reversed
---@return boolean reversed
function Animation:isReversed()
return self._reversed
end
---Set animation playback speed
---@param speed number Speed multiplier (1.0 = normal, 2.0 = double speed, 0.5 = half speed)
function Animation:setSpeed(speed)
if type(speed) == "number" and speed > 0 then
self._speed = speed
end
end
---Get animation playback speed
---@return number speed Current speed multiplier
function Animation:getSpeed()
return self._speed
end
---Seek to a specific time in the animation
---@param time number Time in seconds (clamped to 0-duration)
function Animation:seek(time)
if type(time) == "number" then
self.elapsed = math.max(0, math.min(time, self.duration))
self._resultDirty = true
end
end
---Get current animation state
---@return string state Current state: "pending", "playing", "paused", "completed", "cancelled"
function Animation:getState()
return self._state
end
---Cancel the animation
---@param element table? Optional element reference for callback
function Animation:cancel(element)
if self._state ~= "cancelled" and self._state ~= "completed" then
self._state = "cancelled"
if self.onCancel and type(self.onCancel) == "function" then
local success, err = pcall(self.onCancel, self, element)
if not success then
print(string.format("[Animation] onCancel error: %s", tostring(err)))
end
end
end
end
---Reset the animation to its initial state
function Animation:reset()
self.elapsed = 0
self._hasStarted = false
self._paused = false
self._state = "pending"
self._resultDirty = true
end
---Get the current progress of the animation
---@return number progress Progress from 0 to 1
function Animation:getProgress()
return math.min(self.elapsed / self.duration, 1)
end
--- Create a simple fade animation
---@param duration number Duration in seconds
---@param fromOpacity number Starting opacity (0-1)

View File

@@ -425,6 +425,35 @@ function Color.parse(value)
return Color.sanitizeColor(value, Color.new(0, 0, 0, 1))
end
--- Linear interpolation between two colors
---@param colorA Color Starting color
---@param colorB Color Ending color
---@param t number Interpolation factor (0-1)
---@return Color color Interpolated color
function Color.lerp(colorA, colorB, t)
-- Sanitize inputs
if type(colorA) ~= "table" or getmetatable(colorA) ~= Color then
colorA = Color.new(0, 0, 0, 1)
end
if type(colorB) ~= "table" or getmetatable(colorB) ~= Color then
colorB = Color.new(0, 0, 0, 1)
end
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
t = 0
end
-- Clamp t to 0-1 range
t = math.max(0, math.min(1, t))
-- Linear interpolation for each channel
local r = colorA.r * (1 - t) + colorB.r * t
local g = colorA.g * (1 - t) + colorB.g * t
local b = colorA.b * (1 - t) + colorB.b * t
local a = colorA.a * (1 - t) + colorB.a * t
return Color.new(r, g, b, a)
end
-- Export ErrorHandler initializer
Color.initializeErrorHandler = initializeErrorHandler

View File

@@ -109,6 +109,8 @@
---@field objectFit "fill"|"contain"|"cover"|"scale-down"|"none"? -- Image fit mode (default: "fill")
---@field objectPosition string? -- Image position like "center center", "top left", "50% 50%" (default: "center center")
---@field imageOpacity number? -- Image opacity 0-1 (default: 1, combines with element opacity)
---@field imageRepeat "no-repeat"|"repeat"|"repeat-x"|"repeat-y"|"space"|"round"? -- Image repeat/tiling mode (default: "no-repeat")
---@field imageTint Color? -- Color to tint the image (default: nil/white, no tint)
---@field _loadedImage love.Image? -- Internal: cached loaded image
---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control)
---@field userdata table?
@@ -176,6 +178,7 @@ function Element.new(props, deps)
ImageCache = deps.ImageCache,
Theme = deps.Theme,
Blur = deps.Blur,
Transform = deps.Transform,
utils = deps.utils,
}
@@ -389,6 +392,9 @@ function Element.new(props, deps)
-- Set visibility property (default: "visible")
self.visibility = props.visibility or "visible"
-- Set transform property (optional)
self.transform = props.transform or nil
-- Handle cornerRadius (can be number or table)
if props.cornerRadius then
if type(props.cornerRadius) == "number" then
@@ -451,6 +457,23 @@ function Element.new(props, deps)
end
self.imageOpacity = props.imageOpacity or 1
-- Validate and set imageRepeat
if props.imageRepeat then
local validImageRepeat = {
["no-repeat"] = "no-repeat",
["repeat"] = "repeat",
["repeat-x"] = "repeat-x",
["repeat-y"] = "repeat-y",
space = "space",
round = "round"
}
self._deps.utils.validateEnum(props.imageRepeat, validImageRepeat, "imageRepeat")
end
self.imageRepeat = props.imageRepeat or "no-repeat"
-- Set imageTint
self.imageTint = props.imageTint
-- Auto-load image if imagePath is provided
if self.imagePath and not self.image then
local loadedImage, err = self._deps.ImageCache.load(self.imagePath)
@@ -483,6 +506,8 @@ function Element.new(props, deps)
objectFit = self.objectFit,
objectPosition = self.objectPosition,
imageOpacity = self.imageOpacity,
imageRepeat = self.imageRepeat,
imageTint = self.imageTint,
contentBlur = self.contentBlur,
backdropBlur = self.backdropBlur,
}, rendererDeps)
@@ -2026,17 +2051,75 @@ function Element:update(dt)
-- Update animation if exists
if self.animation then
local finished = self.animation:update(dt)
-- Ensure animation has Color module reference for color interpolation
if not self.animation._Color and self._deps.Color then
self.animation:setColorModule(self._deps.Color)
end
-- Ensure animation has Transform module reference for transform interpolation
if not self.animation._Transform and self._deps.Transform then
self.animation:setTransformModule(self._deps.Transform)
end
local finished = self.animation:update(dt, self)
if finished then
-- Animation:update() already called onComplete callback
self.animation = nil -- remove finished animation
else
-- Apply animation interpolation during update
local anim = self.animation:interpolate()
-- Apply numeric properties
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.x = anim.x or self.x
self.y = anim.y or self.y
self.gap = anim.gap or self.gap
self.imageOpacity = anim.imageOpacity or self.imageOpacity
self.scrollbarWidth = anim.scrollbarWidth or self.scrollbarWidth
self.borderWidth = anim.borderWidth or self.borderWidth
self.fontSize = anim.fontSize or self.fontSize
self.lineHeight = anim.lineHeight or self.lineHeight
-- Apply color properties
if anim.backgroundColor then
self.backgroundColor = anim.backgroundColor
end
if anim.borderColor then
self.borderColor = anim.borderColor
end
if anim.textColor then
self.textColor = anim.textColor
end
if anim.scrollbarColor then
self.scrollbarColor = anim.scrollbarColor
end
if anim.scrollbarBackgroundColor then
self.scrollbarBackgroundColor = anim.scrollbarBackgroundColor
end
if anim.imageTint then
self.imageTint = anim.imageTint
end
-- Apply table properties
if anim.padding then
self.padding = anim.padding
end
if anim.margin then
self.margin = anim.margin
end
if anim.cornerRadius then
self.cornerRadius = anim.cornerRadius
end
-- Apply transform property
if anim.transform then
self.transform = anim.transform
end
-- Backward compatibility: Update background color with interpolated opacity
if anim.opacity and not anim.backgroundColor then
self.backgroundColor.a = anim.opacity
end
end
@@ -2741,4 +2824,85 @@ function Element:_trackActiveAnimations()
end
end
--- Set image tint color
---@param color Color Color to tint the image
function Element:setImageTint(color)
self.imageTint = color
if self._renderer then
self._renderer.imageTint = color
end
end
--- Set image opacity
---@param opacity number Opacity 0-1
function Element:setImageOpacity(opacity)
if opacity ~= nil then
self._deps.utils.validateRange(opacity, 0, 1, "imageOpacity")
end
self.imageOpacity = opacity
if self._renderer then
self._renderer.imageOpacity = opacity
end
end
--- Set image repeat mode
---@param repeatMode string Repeat mode: "no-repeat", "repeat", "repeat-x", "repeat-y", "space", "round"
function Element:setImageRepeat(repeatMode)
local validImageRepeat = {
["no-repeat"] = "no-repeat",
["repeat"] = "repeat",
["repeat-x"] = "repeat-x",
["repeat-y"] = "repeat-y",
space = "space",
round = "round"
}
self._deps.utils.validateEnum(repeatMode, validImageRepeat, "imageRepeat")
self.imageRepeat = repeatMode
if self._renderer then
self._renderer.imageRepeat = repeatMode
end
end
--- Rotate element by angle
---@param angle number Angle in radians
function Element:rotate(angle)
if not self.transform then
self.transform = self._deps.Transform.new({})
end
self.transform.rotate = angle
end
--- Scale element
---@param scaleX number X-axis scale
---@param scaleY number? Y-axis scale (defaults to scaleX)
function Element:scale(scaleX, scaleY)
if not self.transform then
self.transform = self._deps.Transform.new({})
end
self.transform.scaleX = scaleX
self.transform.scaleY = scaleY or scaleX
end
--- Translate element
---@param x number X translation
---@param y number Y translation
function Element:translate(x, y)
if not self.transform then
self.transform = self._deps.Transform.new({})
end
self.transform.translateX = x
self.transform.translateY = y
end
--- Set transform origin
---@param originX number X origin (0-1, where 0.5 is center)
---@param originY number Y origin (0-1, where 0.5 is center)
function Element:setTransformOrigin(originX, originY)
if not self.transform then
self.transform = self._deps.Transform.new({})
end
self.transform.originX = originX
self.transform.originY = originY
end
return Element

View File

@@ -197,7 +197,8 @@ end
---@param fitMode string? -- Object-fit mode (default: "fill")
---@param objectPosition string? -- Object-position (default: "center center")
---@param opacity number? -- Opacity 0-1 (default: 1)
function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity)
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition, opacity, tintColor)
if not image then
return -- Nothing to draw
end
@@ -212,8 +213,12 @@ function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition,
-- Save current color
local r, g, b, a = love.graphics.getColor()
-- Apply opacity
love.graphics.setColor(1, 1, 1, opacity)
-- Apply opacity and tint
if tintColor then
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
else
love.graphics.setColor(1, 1, 1, opacity)
end
-- Draw image
if params.sx ~= 0 or params.sy ~= 0 or params.sw ~= imgWidth or params.sh ~= imgHeight then
@@ -229,4 +234,139 @@ function ImageRenderer.draw(image, x, y, width, height, fitMode, objectPosition,
love.graphics.setColor(r, g, b, a)
end
--- Draw an image with tiling/repeat mode
---@param image love.Image -- Image to draw
---@param x number -- X position of bounds
---@param y number -- Y position of bounds
---@param width number -- Width of bounds
---@param height number -- Height of bounds
---@param repeatMode string? -- Repeat mode: "repeat", "repeat-x", "repeat-y", "no-repeat", "space", "round" (default: "no-repeat")
---@param opacity number? -- Opacity 0-1 (default: 1)
---@param tintColor Color? -- Color to tint the image (default: white/no tint)
function ImageRenderer.drawTiled(image, x, y, width, height, repeatMode, opacity, tintColor)
if not image then
return -- Nothing to draw
end
opacity = opacity or 1
repeatMode = repeatMode or "no-repeat"
local imgWidth, imgHeight = image:getDimensions()
-- Save current color
local r, g, b, a = love.graphics.getColor()
-- Apply opacity and tint
if tintColor then
love.graphics.setColor(tintColor.r, tintColor.g, tintColor.b, tintColor.a * opacity)
else
love.graphics.setColor(1, 1, 1, opacity)
end
if repeatMode == "no-repeat" then
-- Just draw once, no tiling
love.graphics.draw(image, x, y)
elseif repeatMode == "repeat" then
-- Tile in both directions
local tilesX = math.ceil(width / imgWidth)
local tilesY = math.ceil(height / imgHeight)
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth)
local drawY = y + (tileY * imgHeight)
-- Calculate how much of the tile to draw (for partial tiles at edges)
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
if drawWidth < imgWidth or drawHeight < imgHeight then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, drawWidth, drawHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, drawX, drawY)
else
-- Draw full tile
love.graphics.draw(image, drawX, drawY)
end
end
end
elseif repeatMode == "repeat-x" then
-- Tile horizontally only
local tilesX = math.ceil(width / imgWidth)
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth)
local drawWidth = math.min(imgWidth, width - (tileX * imgWidth))
if drawWidth < imgWidth then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, drawWidth, imgHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, drawX, y)
else
-- Draw full tile
love.graphics.draw(image, drawX, y)
end
end
elseif repeatMode == "repeat-y" then
-- Tile vertically only
local tilesY = math.ceil(height / imgHeight)
for tileY = 0, tilesY - 1 do
local drawY = y + (tileY * imgHeight)
local drawHeight = math.min(imgHeight, height - (tileY * imgHeight))
if drawHeight < imgHeight then
-- Use quad for partial tile
local quad = love.graphics.newQuad(0, 0, imgWidth, drawHeight, imgWidth, imgHeight)
love.graphics.draw(image, quad, x, drawY)
else
-- Draw full tile
love.graphics.draw(image, x, drawY)
end
end
elseif repeatMode == "space" then
-- Distribute tiles with even spacing
local tilesX = math.floor(width / imgWidth)
local tilesY = math.floor(height / imgHeight)
if tilesX < 1 then tilesX = 1 end
if tilesY < 1 then tilesY = 1 end
local spaceX = tilesX > 1 and (width - (tilesX * imgWidth)) / (tilesX - 1) or 0
local spaceY = tilesY > 1 and (height - (tilesY * imgHeight)) / (tilesY - 1) or 0
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * (imgWidth + spaceX))
local drawY = y + (tileY * (imgHeight + spaceY))
love.graphics.draw(image, drawX, drawY)
end
end
elseif repeatMode == "round" then
-- Scale tiles to fit bounds exactly
local tilesX = math.max(1, math.round(width / imgWidth))
local tilesY = math.max(1, math.round(height / imgHeight))
local scaleX = width / (tilesX * imgWidth)
local scaleY = height / (tilesY * imgHeight)
for tileY = 0, tilesY - 1 do
for tileX = 0, tilesX - 1 do
local drawX = x + (tileX * imgWidth * scaleX)
local drawY = y + (tileY * imgHeight * scaleY)
love.graphics.draw(image, drawX, drawY, 0, scaleX, scaleY)
end
end
else
ErrorHandler.warn("ImageRenderer", "VAL_007", string.format("Invalid repeat mode: '%s'. Using 'no-repeat'", tostring(repeatMode)), {
repeatMode = repeatMode,
fallback = "no-repeat"
})
love.graphics.draw(image, x, y)
end
-- Restore color
love.graphics.setColor(r, g, b, a)
end
return ImageRenderer

View File

@@ -35,7 +35,7 @@ local ErrorHandler
--- Create a new Renderer instance
---@param config table Configuration table with rendering properties
---@param deps table Dependencies {Color, RoundedRect, NinePatch, ImageRenderer, ImageCache, Theme, Blur, utils}
---@param deps table Dependencies {Color, RoundedRect, NinePatch, ImageRenderer, ImageCache, Theme, Blur, Transform, utils}
function Renderer.new(config, deps)
local Color = deps.Color
local ImageCache = deps.ImageCache
@@ -50,6 +50,7 @@ function Renderer.new(config, deps)
self._ImageCache = ImageCache
self._Theme = deps.Theme
self._Blur = deps.Blur
self._Transform = deps.Transform
self._utils = deps.utils
self._FONT_CACHE = deps.utils.FONT_CACHE
self._TextAlign = deps.utils.enums.TextAlign
@@ -90,6 +91,8 @@ function Renderer.new(config, deps)
self.objectFit = config.objectFit or "fill"
self.objectPosition = config.objectPosition or "center center"
self.imageOpacity = config.imageOpacity or 1
self.imageRepeat = config.imageRepeat or "no-repeat"
self.imageTint = config.imageTint
-- Blur effects
self.contentBlur = config.contentBlur
@@ -193,8 +196,14 @@ function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, conten
love.graphics.setStencilTest("greater", 0)
end
-- Draw the image
self._ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity)
-- Draw the image based on repeat mode
if self.imageRepeat and self.imageRepeat ~= "no-repeat" then
-- Use tiled rendering
self._ImageRenderer.drawTiled(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.imageRepeat, finalOpacity, self.imageTint)
else
-- Use standard fit-based rendering
self._ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity, self.imageTint)
end
-- Clear stencil if it was used
if hasCornerRadius then
@@ -347,6 +356,12 @@ function Renderer:draw(backdropCanvas)
local borderBoxWidth = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right)
local borderBoxHeight = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom)
-- Apply transform if exists
local hasTransform = element.transform and self._Transform and not self._Transform.isIdentity(element.transform)
if hasTransform then
self._Transform.apply(element.transform, element.x, element.y, element.width, element.height)
end
-- LAYER 0.5: Draw backdrop blur if configured (before background)
if self.backdropBlur and self.backdropBlur.intensity > 0 and backdropCanvas then
local blurInstance = self:getBlurInstance()
@@ -366,6 +381,11 @@ function Renderer:draw(backdropCanvas)
-- LAYER 3: Draw borders on top of theme
self:_drawBorders(element.x, element.y, borderBoxWidth, borderBoxHeight)
-- Unapply transform if it was applied
if hasTransform then
self._Transform.unapply()
end
-- Stop performance timing
if Performance and Performance.isEnabled() and elementId then

147
modules/Transform.lua Normal file
View File

@@ -0,0 +1,147 @@
--- Transform module for 2D transformations (rotate, scale, translate, skew)
---@class Transform
---@field rotate number? Rotation in radians (default: 0)
---@field scaleX number? X-axis scale (default: 1)
---@field scaleY number? Y-axis scale (default: 1)
---@field translateX number? X translation in pixels (default: 0)
---@field translateY number? Y translation in pixels (default: 0)
---@field skewX number? X-axis skew in radians (default: 0)
---@field skewY number? Y-axis skew in radians (default: 0)
---@field originX number? Transform origin X (0-1, default: 0.5)
---@field originY number? Transform origin Y (0-1, default: 0.5)
local Transform = {}
Transform.__index = Transform
--- Create a new transform instance
---@param props TransformProps?
---@return Transform transform
function Transform.new(props)
props = props or {}
local self = setmetatable({}, Transform)
self.rotate = props.rotate or 0
self.scaleX = props.scaleX or 1
self.scaleY = props.scaleY or 1
self.translateX = props.translateX or 0
self.translateY = props.translateY or 0
self.skewX = props.skewX or 0
self.skewY = props.skewY or 0
self.originX = props.originX or 0.5
self.originY = props.originY or 0.5
return self
end
--- Apply transform to LÖVE graphics context
---@param transform Transform Transform instance
---@param x number Element x position
---@param y number Element y position
---@param width number Element width
---@param height number Element height
function Transform.apply(transform, x, y, width, height)
if not transform then
return
end
-- Calculate transform origin
local ox = x + width * transform.originX
local oy = y + height * transform.originY
-- Apply transform in correct order: translate → rotate → scale → skew
love.graphics.push()
love.graphics.translate(ox, oy)
if transform.rotate ~= 0 then
love.graphics.rotate(transform.rotate)
end
if transform.scaleX ~= 1 or transform.scaleY ~= 1 then
love.graphics.scale(transform.scaleX, transform.scaleY)
end
if transform.skewX ~= 0 or transform.skewY ~= 0 then
love.graphics.shear(transform.skewX, transform.skewY)
end
love.graphics.translate(-ox, -oy)
love.graphics.translate(transform.translateX, transform.translateY)
end
--- Remove transform from LÖVE graphics context
function Transform.unapply()
love.graphics.pop()
end
--- Interpolate between two transforms
---@param from Transform Starting transform
---@param to Transform Ending transform
---@param t number Interpolation factor (0-1)
---@return Transform interpolated
function Transform.lerp(from, to, t)
-- Sanitize inputs
if type(from) ~= "table" then
from = Transform.new()
end
if type(to) ~= "table" then
to = Transform.new()
end
if type(t) ~= "number" or t ~= t or t == math.huge or t == -math.huge then
t = 0
end
-- Clamp t to 0-1 range
t = math.max(0, math.min(1, t))
return Transform.new({
rotate = (from.rotate or 0) * (1 - t) + (to.rotate or 0) * t,
scaleX = (from.scaleX or 1) * (1 - t) + (to.scaleX or 1) * t,
scaleY = (from.scaleY or 1) * (1 - t) + (to.scaleY or 1) * t,
translateX = (from.translateX or 0) * (1 - t) + (to.translateX or 0) * t,
translateY = (from.translateY or 0) * (1 - t) + (to.translateY or 0) * t,
skewX = (from.skewX or 0) * (1 - t) + (to.skewX or 0) * t,
skewY = (from.skewY or 0) * (1 - t) + (to.skewY or 0) * t,
originX = (from.originX or 0.5) * (1 - t) + (to.originX or 0.5) * t,
originY = (from.originY or 0.5) * (1 - t) + (to.originY or 0.5) * t,
})
end
--- Check if transform is identity (no transformation)
---@param transform Transform
---@return boolean isIdentity
function Transform.isIdentity(transform)
if not transform then
return true
end
return transform.rotate == 0
and transform.scaleX == 1
and transform.scaleY == 1
and transform.translateX == 0
and transform.translateY == 0
and transform.skewX == 0
and transform.skewY == 0
end
--- Clone a transform
---@param transform Transform
---@return Transform clone
function Transform.clone(transform)
if not transform then
return Transform.new()
end
return Transform.new({
rotate = transform.rotate,
scaleX = transform.scaleX,
scaleY = transform.scaleY,
translateX = transform.translateX,
translateY = transform.translateY,
skewX = transform.skewX,
skewY = transform.skewY,
originX = transform.originX,
originY = transform.originY,
})
end
return Transform

View File

@@ -125,3 +125,15 @@ local ElementProps = {}
---@field bottom boolean
---@field left boolean
local Border = {}
---@class TransformProps
---@field rotate number? Rotation in radians (default: 0)
---@field scaleX number? X-axis scale (default: 1)
---@field scaleY number? Y-axis scale (default: 1)
---@field translateX number? X translation in pixels (default: 0)
---@field translateY number? Y translation in pixels (default: 0)
---@field skewX number? X-axis skew in radians (default: 0)
---@field skewY number? Y-axis skew in radians (default: 0)
---@field originX number? Transform origin X (0-1, default: 0.5)
---@field originY number? Transform origin Y (0-1, default: 0.5)
local TransformProps

View File

@@ -64,6 +64,15 @@ local enums = {
XL3 = "3xl",
XL4 = "4xl",
},
---@enum ImageRepeat
ImageRepeat = {
NO_REPEAT = "no-repeat",
REPEAT = "repeat",
REPEAT_X = "repeat-x",
REPEAT_Y = "repeat-y",
SPACE = "space",
ROUND = "round",
},
}
--- Get current keyboard modifiers state