Files
FlexLove/modules/Renderer.lua
2025-12-03 22:19:27 -05:00

975 lines
35 KiB
Lua

---@class Renderer
---@field backgroundColor Color
---@field borderColor Color
---@field opacity number
---@field border {top:boolean, right:boolean, bottom:boolean, left:boolean}
---@field cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number}
---@field theme string?
---@field themeComponent string?
---@field _themeState string
---@field imagePath string?
---@field image love.Image?
---@field _loadedImage love.Image?
---@field objectFit string
---@field objectPosition string
---@field imageOpacity number
---@field contentBlur {intensity:number, quality:number}?
---@field backdropBlur {intensity:number, quality:number}?
---@field _blurInstance table?
---@field _element Element?
---@field _Color Color
---@field _RoundedRect table
---@field _NinePatch table
---@field _ImageRenderer table
---@field _ImageCache table
---@field _Theme table
---@field _Transform Transform
---@field _Blur Blur
---@field _utils table
---@field _FONT_CACHE table
---@field _TextAlign table
---@field _ErrorHandler ErrorHandler
---@field _Performance Performance? Performance module dependency
local Renderer = {}
Renderer.__index = Renderer
--- Initialize module with shared dependencies
---@param deps table Dependencies {ErrorHandler, Performance}
function Renderer.init(deps)
Renderer._ErrorHandler = deps.ErrorHandler
Renderer._Performance = deps.Performance
end
--- Create a new Renderer instance
---@param config table Configuration table with rendering properties
---@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
local self = setmetatable({}, Renderer)
-- Store dependencies for instance methods
self._Color = Color
self._RoundedRect = deps.RoundedRect
self._NinePatch = deps.NinePatch
self._ImageRenderer = deps.ImageRenderer
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
-- Visual properties
self.backgroundColor = config.backgroundColor or Color.new(0, 0, 0, 0)
self.borderColor = config.borderColor or Color.new(0, 0, 0, 1)
self.opacity = config.opacity or 1
-- Border configuration
self.border = config.border or {
top = false,
right = false,
bottom = false,
left = false,
}
-- Corner radius
self.cornerRadius = config.cornerRadius or {
topLeft = 0,
topRight = 0,
bottomLeft = 0,
bottomRight = 0,
}
-- Theme properties
self.theme = config.theme
self.themeComponent = config.themeComponent
self._themeState = "normal"
-- Image properties
self.imagePath = config.imagePath
self.image = config.image
self._loadedImage = nil
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
self.backdropBlur = config.backdropBlur
self._blurInstance = nil
-- Load image if path provided
if self.imagePath and not self.image then
local loadedImage, err = ImageCache.load(self.imagePath)
if loadedImage then
self._loadedImage = loadedImage
else
self._loadedImage = nil
end
elseif self.image then
self._loadedImage = self.image
else
self._loadedImage = nil
end
return self
end
--- Get or create blur instance for this element
---@return table|nil Blur instance or nil
function Renderer:getBlurInstance()
-- Determine quality from blur settings
local quality = "medium"
if self.contentBlur and self.contentBlur.quality then
quality = self.contentBlur.quality
elseif self.backdropBlur and self.backdropBlur.quality then
quality = self.backdropBlur.quality
end
-- Map string quality to numeric quality (1-10)
local numericQuality = 5 -- default medium
if type(quality) == "string" then
if quality == "low" then
numericQuality = 3
elseif quality == "medium" then
numericQuality = 5
elseif quality == "high" then
numericQuality = 8
end
elseif type(quality) == "number" then
numericQuality = quality
end
-- Create or reuse blur instance
if not self._blurInstance or self._blurInstance.quality ~= numericQuality then
self._blurInstance = self._Blur.new({ quality = numericQuality })
end
return self._blurInstance
end
--- Set theme state (normal, hover, pressed, disabled, active)
---@param state string The theme state
function Renderer:setThemeState(state)
self._themeState = state
end
--- Draw background layer
---@param x number X position
---@param y number Y position
---@param width number Width
---@param height number Height
---@param drawBackgroundColor table Background color (may have animation applied)
function Renderer:_drawBackground(x, y, width, height, drawBackgroundColor)
local backgroundWithOpacity = self._Color.new(drawBackgroundColor.r, drawBackgroundColor.g, drawBackgroundColor.b, drawBackgroundColor.a * self.opacity)
love.graphics.setColor(backgroundWithOpacity:toRGBA())
self._RoundedRect.draw("fill", x, y, width, height, self.cornerRadius)
end
--- Draw image layer
---@param x number X position (border box)
---@param y number Y position (border box)
---@param paddingLeft number Left padding
---@param paddingTop number Top padding
---@param contentWidth number Content width
---@param contentHeight number Content height
---@param borderBoxWidth number Border box width
---@param borderBoxHeight number Border box height
function Renderer:_drawImage(x, y, paddingLeft, paddingTop, contentWidth, contentHeight, borderBoxWidth, borderBoxHeight)
if not self._loadedImage then
return
end
-- Calculate image bounds (content area - respects padding)
local imageX = x + paddingLeft
local imageY = y + paddingTop
local imageWidth = contentWidth
local imageHeight = contentHeight
-- Combine element opacity with imageOpacity
local finalOpacity = self.opacity * self.imageOpacity
-- Apply cornerRadius clipping if set
local hasCornerRadius = false
if self.cornerRadius then
if type(self.cornerRadius) == "number" then
hasCornerRadius = self.cornerRadius > 0
else
hasCornerRadius = self.cornerRadius.topLeft > 0
or self.cornerRadius.topRight > 0
or self.cornerRadius.bottomLeft > 0
or self.cornerRadius.bottomRight > 0
end
end
if hasCornerRadius then
-- Use stencil to clip image to rounded corners
local success, err = pcall(function()
love.graphics.stencil(function()
self._RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
end, "replace", 1)
love.graphics.setStencilTest("greater", 0)
end)
if not success then
-- Check if it's a stencil buffer error
if err and err:match("stencil") then
local cornerRadiusStr
if type(self.cornerRadius) == "number" then
cornerRadiusStr = tostring(self.cornerRadius)
else
cornerRadiusStr = string.format(
"TL:%d TR:%d BL:%d BR:%d",
self.cornerRadius.topLeft,
self.cornerRadius.topRight,
self.cornerRadius.bottomLeft,
self.cornerRadius.bottomRight
)
end
Renderer._ErrorHandler:warn("Renderer", "IMG_001", {
imagePath = self.imagePath or "unknown",
cornerRadius = cornerRadiusStr,
error = tostring(err),
})
-- Continue without corner radius
hasCornerRadius = false
else
-- Re-throw if it's a different error
error(err, 2)
end
end
end
-- 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
love.graphics.setStencilTest()
end
end
--- Draw theme layer (9-patch)
---@param x number X position
---@param y number Y position
---@param borderBoxWidth number Border box width
---@param borderBoxHeight number Border box height
---@param scaleCorners boolean Whether to scale corners (from element)
---@param scalingAlgorithm string Scaling algorithm (from element)
function Renderer:_drawTheme(x, y, borderBoxWidth, borderBoxHeight, scaleCorners, scalingAlgorithm)
if not self.themeComponent then
return
end
-- Get the theme to use
local themeToUse = nil
if self.theme then
-- Element specifies a specific theme - load it if needed
if self._Theme.get(self.theme) then
themeToUse = self._Theme.get(self.theme)
else
-- Try to load the theme
pcall(function()
self._Theme.load(self.theme)
end)
themeToUse = self._Theme.get(self.theme)
end
else
-- Use active theme
themeToUse = self._Theme.getActive()
end
if not themeToUse then
return
end
-- Get the component from the theme
local component = themeToUse.components[self.themeComponent]
if not component then
return
end
-- Check for state-specific override
local state = self._themeState
if state and component.states and component.states[state] then
component = component.states[state]
end
-- Use component-specific atlas if available, otherwise use theme atlas
local atlasToUse = component._loadedAtlas or themeToUse.atlas
if atlasToUse and component.regions then
-- Validate component has required structure
local hasAllRegions = component.regions.topLeft
and component.regions.topCenter
and component.regions.topRight
and component.regions.middleLeft
and component.regions.middleCenter
and component.regions.middleRight
and component.regions.bottomLeft
and component.regions.bottomCenter
and component.regions.bottomRight
if hasAllRegions then
-- Pass element-level overrides for scaleCorners and scalingAlgorithm
self._NinePatch.draw(component, atlasToUse, x, y, borderBoxWidth, borderBoxHeight, self.opacity, scaleCorners, scalingAlgorithm)
end
end
end
--- Draw borders
---@param x number X position
---@param y number Y position
---@param borderBoxWidth number Border box width
---@param borderBoxHeight number Border box height
function Renderer:_drawBorders(x, y, borderBoxWidth, borderBoxHeight)
-- OPTIMIZATION: Early exit if no border (nil or all false)
if not self.border then
return
end
-- Handle border as number (uniform border width)
if type(self.border) == "number" then
local borderColorWithOpacity = self._Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity)
love.graphics.setColor(borderColorWithOpacity:toRGBA())
self._RoundedRect.draw("line", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
return
end
local borderColorWithOpacity = self._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
self._RoundedRect.draw("line", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
else
-- Draw individual borders (without rounded corners for partial borders)
if self.border.top then
love.graphics.line(x, y, x + borderBoxWidth, y)
end
if self.border.bottom then
love.graphics.line(x, y + borderBoxHeight, x + borderBoxWidth, y + borderBoxHeight)
end
if self.border.left then
love.graphics.line(x, y, x, y + borderBoxHeight)
end
if self.border.right then
love.graphics.line(x + borderBoxWidth, y, x + borderBoxWidth, y + borderBoxHeight)
end
end
end
--- Main draw method - renders all visual layers
---@param element Element The parent Element instance
---@param backdropCanvas table|nil Backdrop canvas for backdrop blur
function Renderer:draw(element, backdropCanvas)
if not element then
Renderer._ErrorHandler:warn("Renderer", "SYS_002", {
method = "draw",
})
return
end
-- Start performance timing
local elementId
if Renderer._Performance and Renderer._Performance.enabled and element then
elementId = element.id or "unnamed"
Renderer._Performance:startTimer("render_" .. elementId)
Renderer._Performance:incrementCounter("draw_calls", 1)
end
-- Early exit if element is invisible (optimization)
if self.opacity <= 0 then
if Renderer._Performance and Renderer._Performance.enabled and elementId then
Renderer._Performance:stopTimer("render_" .. elementId)
end
return
end
-- Handle opacity during animation
local drawBackgroundColor = self.backgroundColor
if element.animation then
local anim = element.animation:interpolate()
if anim.opacity then
drawBackgroundColor = self._Color.new(self.backgroundColor.r, self.backgroundColor.g, self.backgroundColor.b, anim.opacity)
end
end
-- Cache border box dimensions for this draw call (optimization)
local borderBoxWidth = 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()
if blurInstance then
-- Use cached blur in immediate mode if element has an ID
local elementId = element.id and element.id ~= "" and element.id or nil
blurInstance:applyBackdropCached(self.backdropBlur.intensity, element.x, element.y, borderBoxWidth, borderBoxHeight, backdropCanvas, elementId)
end
end
-- LAYER 1: Draw backgroundColor first (behind everything)
self:_drawBackground(element.x, element.y, borderBoxWidth, borderBoxHeight, drawBackgroundColor)
-- LAYER 1.5: Draw image on top of backgroundColor (if image exists)
self:_drawImage(element.x, element.y, element.padding.left, element.padding.top, element.width, element.height, borderBoxWidth, borderBoxHeight)
-- LAYER 2: Draw theme on top of backgroundColor (if theme exists)
self:_drawTheme(element.x, element.y, borderBoxWidth, borderBoxHeight, element.scaleCorners, element.scalingAlgorithm)
-- 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 Renderer._Performance and Renderer._Performance.enabled and elementId then
Renderer._Performance:stopTimer("render_" .. elementId)
end
end
--- Get font for element (resolves from theme or fontFamily)
---@param element table Reference to the parent Element instance
---@return love.Font
function Renderer:getFont(element)
return self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager)
end
--- Wrap a line of text based on element's textWrap mode
---@param element table Reference to the parent Element instance
---@param line string The line of text to wrap
---@param maxWidth number Maximum width for wrapping
---@return table Array of {text, startIdx, endIdx}
function Renderer:wrapLine(element, line, maxWidth)
-- UTF-8 support
local utf8 = utf8 or require("utf8")
if not element.editable then
return { { text = line, startIdx = 0, endIdx = utf8.len(line) } }
end
local font = self:getFont(element)
local wrappedParts = {}
local currentLine = ""
local startIdx = 0
-- Helper function to extract a UTF-8 character by character index
local function getUtf8Char(str, charIndex)
local byteStart = utf8.offset(str, charIndex)
if not byteStart then
return ""
end
local byteEnd = utf8.offset(str, charIndex + 1)
if byteEnd then
return str:sub(byteStart, byteEnd - 1)
else
return str:sub(byteStart)
end
end
if element.textWrap == "word" then
-- Tokenize into words and whitespace, preserving exact spacing
local tokens = {}
local pos = 1
local lineLen = utf8.len(line)
while pos <= lineLen do
-- Check if current position is whitespace
local char = getUtf8Char(line, pos)
if char:match("%s") then
-- Collect whitespace sequence
local wsStart = pos
while pos <= lineLen and getUtf8Char(line, pos):match("%s") do
pos = pos + 1
end
table.insert(tokens, {
type = "space",
text = line:sub(utf8.offset(line, wsStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line),
startPos = wsStart - 1,
length = pos - wsStart,
})
else
-- Collect word (non-whitespace sequence)
local wordStart = pos
while pos <= lineLen and not getUtf8Char(line, pos):match("%s") do
pos = pos + 1
end
table.insert(tokens, {
type = "word",
text = line:sub(utf8.offset(line, wordStart), utf8.offset(line, pos) and utf8.offset(line, pos) - 1 or #line),
startPos = wordStart - 1,
length = pos - wordStart,
})
end
end
-- Process tokens and wrap
local charPos = 0 -- Track our position in the original line
for i, token in ipairs(tokens) do
if token.type == "word" then
local testLine = currentLine .. token.text
local width = font:getWidth(testLine)
if width > maxWidth and currentLine ~= "" then
-- Current line is full, wrap before this word
local currentLineLen = utf8.len(currentLine)
table.insert(wrappedParts, {
text = currentLine,
startIdx = startIdx,
endIdx = startIdx + currentLineLen,
})
startIdx = charPos
currentLine = token.text
charPos = charPos + token.length
-- Check if the word itself is too long - if so, break it with character wrapping
if font:getWidth(token.text) > maxWidth then
local wordLen = utf8.len(token.text)
local charLine = ""
local charStartIdx = startIdx
for j = 1, wordLen do
local char = getUtf8Char(token.text, j)
local testCharLine = charLine .. char
local charWidth = font:getWidth(testCharLine)
if charWidth > maxWidth and charLine ~= "" then
table.insert(wrappedParts, {
text = charLine,
startIdx = charStartIdx,
endIdx = charStartIdx + utf8.len(charLine),
})
charStartIdx = charStartIdx + utf8.len(charLine)
charLine = char
else
charLine = testCharLine
end
end
currentLine = charLine
startIdx = charStartIdx
end
elseif width > maxWidth and currentLine == "" then
-- Word is too long to fit on a line by itself - use character wrapping
local wordLen = utf8.len(token.text)
local charLine = ""
local charStartIdx = startIdx
for j = 1, wordLen do
local char = getUtf8Char(token.text, j)
local testCharLine = charLine .. char
local charWidth = font:getWidth(testCharLine)
if charWidth > maxWidth and charLine ~= "" then
table.insert(wrappedParts, {
text = charLine,
startIdx = charStartIdx,
endIdx = charStartIdx + utf8.len(charLine),
})
charStartIdx = charStartIdx + utf8.len(charLine)
charLine = char
else
charLine = testCharLine
end
end
currentLine = charLine
startIdx = charStartIdx
charPos = charPos + token.length
else
currentLine = testLine
charPos = charPos + token.length
end
else
-- It's whitespace - add to current line
currentLine = currentLine .. token.text
charPos = charPos + token.length
end
end
else
-- Character wrapping
local lineLength = utf8.len(line)
for i = 1, lineLength do
local char = getUtf8Char(line, i)
local testLine = currentLine .. char
local width = font:getWidth(testLine)
if width > maxWidth and currentLine ~= "" then
table.insert(wrappedParts, {
text = currentLine,
startIdx = startIdx,
endIdx = startIdx + utf8.len(currentLine),
})
currentLine = char
startIdx = i - 1
else
currentLine = testLine
end
end
end
-- Add remaining text
if currentLine ~= "" then
table.insert(wrappedParts, {
text = currentLine,
startIdx = startIdx,
endIdx = startIdx + utf8.len(currentLine),
})
end
-- Ensure at least one part
if #wrappedParts == 0 then
table.insert(wrappedParts, {
text = "",
startIdx = 0,
endIdx = 0,
})
end
return wrappedParts
end
--- Draw text content (includes text, cursor, selection, placeholder, password masking)
---@param element table Reference to the parent Element instance
function Renderer:drawText(element)
-- Update text layout if dirty (for multiline auto-grow)
if element._textEditor then
element._textEditor:_updateTextIfDirty(element)
element._textEditor:updateAutoGrowHeight(element)
end
-- For editable elements, use TextEditor buffer; for non-editable, use text
local displayText = element._textEditor and element._textEditor:getText() or element.text
local isPlaceholder = false
local isPasswordMasked = false
-- Show placeholder if editable and empty
if element.editable and (not displayText or displayText == "") and element.placeholder then
displayText = element.placeholder
isPlaceholder = true
end
-- Apply password masking if enabled
if element.passwordMode and displayText and displayText ~= "" and not isPlaceholder then
local maskedText = string.rep("", utf8.len(displayText))
displayText = maskedText
isPasswordMasked = true
end
if displayText and displayText ~= "" then
local textColor = isPlaceholder
and self._Color.new(element.textColor.r * 0.5, element.textColor.g * 0.5, element.textColor.b * 0.5, element.textColor.a * 0.5)
or element.textColor
local textColorWithOpacity = self._Color.new(textColor.r, textColor.g, textColor.b, textColor.a * self.opacity)
love.graphics.setColor(textColorWithOpacity:toRGBA())
local origFont = love.graphics.getFont()
if element.textSize then
-- Use cached font instead of creating new one every frame
local font = self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager)
love.graphics.setFont(font)
end
local font = love.graphics.getFont()
local textWidth = font:getWidth(displayText)
local textHeight = font:getHeight()
local tx, ty
-- Text is drawn in the content box (inside padding)
-- For 9-patch components, use contentPadding if available
local textPaddingLeft = element.padding.left
local textPaddingTop = element.padding.top
local textAreaWidth = element.width
local textAreaHeight = element.height
-- Check if we should use 9-patch contentPadding for text positioning
local scaledContentPadding = element:getScaledContentPadding()
if scaledContentPadding then
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)
textPaddingLeft = scaledContentPadding.left
textPaddingTop = scaledContentPadding.top
textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right
textAreaHeight = borderBoxHeight - scaledContentPadding.top - scaledContentPadding.bottom
end
local contentX = element.x + textPaddingLeft
local contentY = element.y + textPaddingTop
-- Check if text wrapping is enabled
if element.textWrap and (element.textWrap == "word" or element.textWrap == "char" or element.textWrap == true) then
-- Use printf for wrapped text
local align = "left"
if element.textAlign == self._TextAlign.CENTER then
align = "center"
elseif element.textAlign == self._TextAlign.END then
align = "right"
elseif element.textAlign == self._TextAlign.JUSTIFY then
align = "justify"
end
tx = contentX
ty = contentY
-- Use printf with the available width for wrapping
love.graphics.printf(displayText, tx, ty, textAreaWidth, align)
else
-- Use regular print for non-wrapped text
if element.textAlign == self._TextAlign.START then
tx = contentX
ty = contentY
elseif element.textAlign == self._TextAlign.CENTER then
tx = contentX + (textAreaWidth - textWidth) / 2
ty = contentY + (textAreaHeight - textHeight) / 2
elseif element.textAlign == self._TextAlign.END then
tx = contentX + textAreaWidth - textWidth - 10
ty = contentY + textAreaHeight - textHeight - 10
elseif element.textAlign == self._TextAlign.JUSTIFY then
--- need to figure out spreading
tx = contentX
ty = contentY
end
-- Apply scroll offset for editable single-line inputs
if element.editable and not element.multiline and element._textScrollX then
tx = tx - element._textScrollX
end
-- Use scissor to clip text to content area for editable inputs
if element.editable and not element.multiline then
love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight)
end
love.graphics.print(displayText, tx, ty)
-- Reset scissor
if element.editable and not element.multiline then
love.graphics.setScissor()
end
end
-- Draw cursor for focused editable elements (even if text is empty)
if element._textEditor and element._textEditor:isFocused() and element._textEditor._cursorVisible then
local cursorColor = element.cursorColor or element.textColor
local cursorWithOpacity = self._Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * self.opacity)
love.graphics.setColor(cursorWithOpacity:toRGBA())
-- Calculate cursor position using TextEditor method
local cursorRelX, cursorRelY = element._textEditor:_getCursorScreenPosition(element)
local cursorX = contentX + cursorRelX
local cursorY = contentY + cursorRelY
local cursorHeight = textHeight
-- Apply scroll offset for single-line inputs
if not element.multiline and element._textEditor._textScrollX then
cursorX = cursorX - element._textEditor._textScrollX
end
-- Apply scissor for single-line editable inputs
if not element.multiline then
love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight)
end
-- Draw cursor line
love.graphics.rectangle("fill", cursorX, cursorY, 2, cursorHeight)
-- Reset scissor
if not element.multiline then
love.graphics.setScissor()
end
end
-- Draw selection highlight for editable elements
if element._textEditor and element._textEditor:isFocused() and element._textEditor:hasSelection() and element.text and element.text ~= "" then
local selStart, selEnd = element._textEditor:getSelection()
local selectionColor = element.selectionColor or self._Color.new(0.3, 0.5, 0.8, 0.5)
local selectionWithOpacity = self._Color.new(selectionColor.r, selectionColor.g, selectionColor.b, selectionColor.a * self.opacity)
-- Get selection rectangles from TextEditor
local selectionRects = element._textEditor:_getSelectionRects(element, selStart, selEnd)
-- Apply scissor for single-line editable inputs
if not element.multiline then
love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight)
end
-- Draw selection background rectangles
love.graphics.setColor(selectionWithOpacity:toRGBA())
for _, rect in ipairs(selectionRects) do
local rectX = contentX + rect.x
local rectY = contentY + rect.y
if not element.multiline and element._textEditor._textScrollX then
rectX = rectX - element._textEditor._textScrollX
end
love.graphics.rectangle("fill", rectX, rectY, rect.width, rect.height)
end
-- Reset scissor
if not element.multiline then
love.graphics.setScissor()
end
end
if element.textSize then
love.graphics.setFont(origFont)
end
end
-- Draw cursor for focused editable elements even when empty
if element._textEditor and element._textEditor:isFocused() and element._textEditor._cursorVisible and (not displayText or displayText == "") then
-- Set up font for cursor rendering
local origFont = love.graphics.getFont()
if element.textSize then
local font = self._utils.getFont(element.textSize, element.fontFamily, element.themeComponent, element._themeManager)
love.graphics.setFont(font)
end
local font = love.graphics.getFont()
local textHeight = font:getHeight()
-- Calculate text area position
local textPaddingLeft = element.padding.left
local textPaddingTop = element.padding.top
local scaledContentPadding = element:getScaledContentPadding()
if scaledContentPadding then
textPaddingLeft = scaledContentPadding.left
textPaddingTop = scaledContentPadding.top
end
local contentX = element.x + textPaddingLeft
local contentY = element.y + textPaddingTop
-- Draw cursor
local cursorColor = element.cursorColor or element.textColor
local cursorWithOpacity = self._Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * self.opacity)
love.graphics.setColor(cursorWithOpacity:toRGBA())
love.graphics.rectangle("fill", contentX, contentY, 2, textHeight)
if element.textSize then
love.graphics.setFont(origFont)
end
end
end
--- Draw scrollbars (both vertical and horizontal)
---@param element table Reference to the parent Element instance
---@param x number X position
---@param y number Y position
---@param w number Width
---@param h number Height
---@param dims table Scrollbar dimensions from _calculateScrollbarDimensions
function Renderer:drawScrollbars(element, x, y, w, h, dims)
-- Vertical scrollbar
if dims.vertical.visible and not element.hideScrollbars.vertical then
-- Position scrollbar within content area (x, y is border-box origin)
local contentX = x + element.padding.left
local contentY = y + element.padding.top
local trackX = contentX + w - element.scrollbarWidth - element.scrollbarPadding
local trackY = contentY + element.scrollbarPadding
-- Determine thumb color based on state (independent for vertical)
local thumbColor = element.scrollbarColor
if element._scrollbarDragging and element._hoveredScrollbar == "vertical" then
-- Active state: brighter
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
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
love.graphics.setColor(element.scrollbarTrackColor:toRGBA())
love.graphics.rectangle("fill", trackX, trackY, element.scrollbarWidth, dims.vertical.trackHeight, element.scrollbarRadius)
-- Draw thumb with state-based color
love.graphics.setColor(thumbColor:toRGBA())
love.graphics.rectangle("fill", trackX, trackY + dims.vertical.thumbY, element.scrollbarWidth, dims.vertical.thumbHeight, element.scrollbarRadius)
end
-- Horizontal scrollbar
if dims.horizontal.visible and not element.hideScrollbars.horizontal then
-- Position scrollbar within content area (x, y is border-box origin)
local contentX = x + element.padding.left
local contentY = y + element.padding.top
local trackX = contentX + element.scrollbarPadding
local trackY = contentY + h - element.scrollbarWidth - element.scrollbarPadding
-- Determine thumb color based on state (independent for horizontal)
local thumbColor = element.scrollbarColor
if element._scrollbarDragging and element._hoveredScrollbar == "horizontal" then
-- Active state: brighter
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
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
love.graphics.setColor(element.scrollbarTrackColor:toRGBA())
love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, element.scrollbarWidth, element.scrollbarRadius)
-- Draw thumb with state-based color
love.graphics.setColor(thumbColor:toRGBA())
love.graphics.rectangle("fill", trackX + dims.horizontal.thumbX, trackY, dims.horizontal.thumbWidth, element.scrollbarWidth, element.scrollbarRadius)
end
-- Reset color
love.graphics.setColor(1, 1, 1, 1)
end
--- Draw visual feedback when element is pressed
---@param x number X position
---@param y number Y position
---@param borderBoxWidth number Border box width
---@param borderBoxHeight number Border box height
function Renderer:drawPressedState(x, y, borderBoxWidth, borderBoxHeight)
love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * self.opacity) -- Semi-transparent gray for pressed state with opacity
self._RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
end
--- Cleanup renderer resources
function Renderer:destroy()
self._loadedImage = nil
self._blurInstance = nil
end
--- Cleanup method to break circular references (for immediate mode)
function Renderer:_cleanup()
-- Renderer doesn't create circular references (no element back-reference)
-- Module refs are singletons
-- In immediate mode, full element cleanup happens via array clearing
end
return Renderer