mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
1231 lines
44 KiB
Lua
1231 lines
44 KiB
Lua
local UTF8 = require((...):match("(.-)[^%.]+$") .. "UTF8")
|
|
|
|
---@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
|
|
self._TextAlignVertical = deps.utils.enums.TextAlignVertical
|
|
|
|
-- 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
|
|
|
|
-- NOTE: border is intentionally NOT cached here. Renderer:draw resolves it from
|
|
-- element.border (source of truth) so retained-mode bare writes and
|
|
-- setProperty("border", ...) both take effect immediately.
|
|
|
|
-- 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 = 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
|
|
|
|
--- Execute a single core draw command (background, image, theme, borders).
|
|
--- Commands are plain tables: { type = "background"|"image"|"theme"|"borders", ... }
|
|
---@param cmd table Command table
|
|
---@param ctx table Resolved draw context
|
|
function Renderer:_executeDrawCommand(cmd, ctx)
|
|
if cmd.type == "background" then
|
|
local c = self._Color.new(cmd.color.r, cmd.color.g, cmd.color.b, cmd.color.a * ctx.opacity)
|
|
love.graphics.setColor(c:toRGBA())
|
|
self._RoundedRect.draw("fill", ctx.x, ctx.y, ctx.borderBoxWidth, ctx.borderBoxHeight, ctx.cornerRadius)
|
|
elseif cmd.type == "image" then
|
|
-- Image value props (imageOpacity/imageRepeat/imageTint/objectFit/
|
|
-- objectPosition) and imagePath are read from the element as the single
|
|
-- source of truth, so retained-mode bare writes (`element.imageOpacity = 0.5`),
|
|
-- the setImage* setters, and setProperty(...) are all immediately
|
|
-- consistent. The renderer's own config is a fallback for standalone
|
|
-- Renderer usage with a sparse element (mirrors `element.onEvent or
|
|
-- self.onEvent`); the integrated path always supplies an element whose
|
|
-- _applyProps-bound values take precedence. _loadedImage intentionally
|
|
-- remains on the renderer (the resolved love.Image from the Imageable load
|
|
-- pipeline). See TestRetainedPropertyConsistency.
|
|
local el = self._element
|
|
local imageOpacity = (el and el.imageOpacity) or self.imageOpacity
|
|
local imageRepeat = (el and el.imageRepeat) or self.imageRepeat
|
|
local imageTint = (el and el.imageTint) or self.imageTint
|
|
local objectFit = (el and el.objectFit) or self.objectFit
|
|
local objectPosition = (el and el.objectPosition) or self.objectPosition
|
|
local imagePath = (el and el.imagePath) or self.imagePath
|
|
if not self._loadedImage then
|
|
return
|
|
end
|
|
local img = self._loadedImage
|
|
local imageX = ctx.x + ctx.paddingLeft
|
|
local imageY = ctx.y + ctx.paddingTop
|
|
local finalOpacity = ctx.opacity * imageOpacity
|
|
local hasCornerRadius = false
|
|
if ctx.cornerRadius then
|
|
if type(ctx.cornerRadius) == "number" then
|
|
hasCornerRadius = ctx.cornerRadius > 0
|
|
else
|
|
hasCornerRadius = ctx.cornerRadius.topLeft > 0
|
|
or ctx.cornerRadius.topRight > 0
|
|
or ctx.cornerRadius.bottomLeft > 0
|
|
or ctx.cornerRadius.bottomRight > 0
|
|
end
|
|
end
|
|
if hasCornerRadius then
|
|
local success, err = pcall(function()
|
|
love.graphics.stencil(function()
|
|
self._RoundedRect.draw("fill", ctx.x, ctx.y, ctx.borderBoxWidth, ctx.borderBoxHeight, ctx.cornerRadius)
|
|
end, "replace", 1)
|
|
love.graphics.setStencilTest("greater", 0)
|
|
end)
|
|
if not success then
|
|
if err and err:match("stencil") then
|
|
local cr = ctx.cornerRadius
|
|
local crStr = type(cr) == "number" and tostring(cr)
|
|
or string.format("TL:%d TR:%d BL:%d BR:%d", cr.topLeft, cr.topRight, cr.bottomLeft, cr.bottomRight)
|
|
Renderer._ErrorHandler:warn(
|
|
"Renderer",
|
|
"IMG_001",
|
|
{ imagePath = imagePath or "unknown", cornerRadius = crStr, error = tostring(err) }
|
|
)
|
|
hasCornerRadius = false
|
|
else
|
|
error(err, 2)
|
|
end
|
|
end
|
|
end
|
|
if imageRepeat and imageRepeat ~= "no-repeat" then
|
|
self._ImageRenderer.drawTiled(
|
|
img,
|
|
imageX,
|
|
imageY,
|
|
ctx.contentWidth,
|
|
ctx.contentHeight,
|
|
imageRepeat,
|
|
finalOpacity,
|
|
imageTint
|
|
)
|
|
else
|
|
self._ImageRenderer.draw(
|
|
img,
|
|
imageX,
|
|
imageY,
|
|
ctx.contentWidth,
|
|
ctx.contentHeight,
|
|
objectFit,
|
|
objectPosition,
|
|
finalOpacity,
|
|
imageTint
|
|
)
|
|
end
|
|
if hasCornerRadius then
|
|
love.graphics.setStencilTest()
|
|
end
|
|
elseif cmd.type == "theme" then
|
|
if not cmd.themeComponent then
|
|
return
|
|
end
|
|
local themeToUse = nil
|
|
if self.theme then
|
|
themeToUse = self._Theme.get(self.theme)
|
|
if not themeToUse then
|
|
pcall(function()
|
|
self._Theme.load(self.theme)
|
|
end)
|
|
themeToUse = self._Theme.get(self.theme)
|
|
end
|
|
else
|
|
themeToUse = self._Theme.getActive()
|
|
end
|
|
if not themeToUse then
|
|
return
|
|
end
|
|
local component = themeToUse.components[cmd.themeComponent]
|
|
if not component then
|
|
return
|
|
end
|
|
local state = self._themeState
|
|
if state and component.states and component.states[state] then
|
|
component = component.states[state]
|
|
end
|
|
local atlasToUse = component._loadedAtlas or themeToUse.atlas
|
|
if atlasToUse and component.regions then
|
|
local r = component.regions
|
|
if
|
|
r.topLeft
|
|
and r.topCenter
|
|
and r.topRight
|
|
and r.middleLeft
|
|
and r.middleCenter
|
|
and r.middleRight
|
|
and r.bottomLeft
|
|
and r.bottomCenter
|
|
and r.bottomRight
|
|
then
|
|
self._NinePatch.draw(
|
|
component,
|
|
atlasToUse,
|
|
ctx.x,
|
|
ctx.y,
|
|
ctx.borderBoxWidth,
|
|
ctx.borderBoxHeight,
|
|
ctx.opacity,
|
|
cmd.scaleCorners,
|
|
cmd.scalingAlgorithm
|
|
)
|
|
end
|
|
end
|
|
elseif cmd.type == "borders" then
|
|
local border = cmd.border
|
|
if not border then
|
|
return
|
|
end
|
|
local bc = cmd.borderColor
|
|
local borderColorWithOpacity = self._Color.new(bc.r, bc.g, bc.b, bc.a * ctx.opacity)
|
|
love.graphics.setColor(borderColorWithOpacity:toRGBA())
|
|
local bw, bh = ctx.borderBoxWidth, ctx.borderBoxHeight
|
|
if type(border) == "number" then
|
|
love.graphics.setLineWidth(border)
|
|
self._RoundedRect.draw("line", ctx.x, ctx.y, bw, bh, ctx.cornerRadius)
|
|
love.graphics.setLineWidth(1)
|
|
else
|
|
local allBorders = border.top and border.bottom and border.left and border.right
|
|
local uniformWidth = allBorders
|
|
and type(border.top) == "number"
|
|
and border.top == border.right
|
|
and border.top == border.bottom
|
|
and border.top == border.left
|
|
if uniformWidth then
|
|
love.graphics.setLineWidth(border.top)
|
|
self._RoundedRect.draw("line", ctx.x, ctx.y, bw, bh, ctx.cornerRadius)
|
|
love.graphics.setLineWidth(1)
|
|
else
|
|
if border.top then
|
|
love.graphics.setLineWidth(type(border.top) == "number" and border.top or 1)
|
|
love.graphics.line(ctx.x, ctx.y, ctx.x + bw, ctx.y)
|
|
end
|
|
if border.bottom then
|
|
love.graphics.setLineWidth(type(border.bottom) == "number" and border.bottom or 1)
|
|
love.graphics.line(ctx.x, ctx.y + bh, ctx.x + bw, ctx.y + bh)
|
|
end
|
|
if border.left then
|
|
love.graphics.setLineWidth(type(border.left) == "number" and border.left or 1)
|
|
love.graphics.line(ctx.x, ctx.y, ctx.x, ctx.y + bh)
|
|
end
|
|
if border.right then
|
|
love.graphics.setLineWidth(type(border.right) == "number" and border.right or 1)
|
|
love.graphics.line(ctx.x + bw, ctx.y, ctx.x + bw, ctx.y + bh)
|
|
end
|
|
love.graphics.setLineWidth(1)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
--- Build the render command buffer: resolve draw properties once from the
|
|
--- element (source of truth) and return a flat command list + draw context.
|
|
---@param element table Element instance
|
|
---@param backdropCanvas table|nil
|
|
---@return table cmds, table ctx Command list and resolved context
|
|
function Renderer:_buildCommands(element, backdropCanvas)
|
|
local opacity = element.opacity ~= nil and element.opacity or 1
|
|
local backgroundColor = element.backgroundColor or self._Color.new(0, 0, 0, 0)
|
|
local borderColor = element.borderColor or self._Color.new(0, 0, 0, 1)
|
|
local cornerRadius = element.cornerRadius ~= nil and element.cornerRadius or nil
|
|
local themeComponent = element.themeComponent
|
|
local border = element.border
|
|
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)
|
|
|
|
-- Handle opacity during animation
|
|
local drawBackgroundColor = backgroundColor
|
|
if element.animation then
|
|
local anim = element.animation:interpolate()
|
|
if anim.opacity then
|
|
drawBackgroundColor = self._Color.new(backgroundColor.r, backgroundColor.g, backgroundColor.b, anim.opacity)
|
|
end
|
|
end
|
|
|
|
-- Build resolved context (shared by all commands — eliminates per-method params)
|
|
local ctx = {
|
|
x = element.x,
|
|
y = element.y,
|
|
opacity = opacity,
|
|
cornerRadius = cornerRadius,
|
|
borderBoxWidth = borderBoxWidth,
|
|
borderBoxHeight = borderBoxHeight,
|
|
paddingLeft = element.padding.left,
|
|
paddingTop = element.padding.top,
|
|
contentWidth = element.width,
|
|
contentHeight = element.height,
|
|
backdropCanvas = backdropCanvas,
|
|
}
|
|
|
|
-- Build command list (conditional — only emit layers that have data)
|
|
local cmds = {}
|
|
local n = 0
|
|
|
|
-- LAYER 0.5: backdrop blur (handled separately, not a command — needs canvas access)
|
|
if self.backdropBlur and self.backdropBlur.radius > 0 then
|
|
n = n + 1
|
|
cmds[n] = { type = "backdropBlur", radius = self.backdropBlur.radius } -- executed before background
|
|
end
|
|
|
|
-- LAYER 1: background
|
|
n = n + 1
|
|
cmds[n] = { type = "background", color = drawBackgroundColor }
|
|
|
|
-- LAYER 1.5: image (always emit; _executeCommand early-exits if no image)
|
|
n = n + 1
|
|
cmds[n] = { type = "image" }
|
|
|
|
-- LAYER 2: theme 9-patch
|
|
n = n + 1
|
|
cmds[n] = {
|
|
type = "theme",
|
|
themeComponent = themeComponent,
|
|
scaleCorners = element.scaleCorners,
|
|
scalingAlgorithm = element.scalingAlgorithm,
|
|
}
|
|
|
|
-- LAYER 3: borders
|
|
n = n + 1
|
|
cmds[n] = { type = "borders", borderColor = borderColor, border = border }
|
|
|
|
-- LAYER 4: text (cursor, selection, placeholder, password masking)
|
|
n = n + 1
|
|
cmds[n] = { type = "text" }
|
|
|
|
-- LAYER 4.5: custom draw callback (if provided)
|
|
if element.customDraw then
|
|
n = n + 1
|
|
cmds[n] = { type = "customDraw" }
|
|
end
|
|
|
|
-- NOTE: pressed-state overlay (former Layer 5) is now owned by the Clickable
|
|
-- behavior's onDraw, dispatched from Element:draw. The renderer no longer
|
|
-- branches on element.onEvent for press feedback.
|
|
|
|
return cmds, ctx
|
|
end
|
|
|
|
--- Execute a special render command (backdropBlur, customDraw, pressedState).
|
|
--- These interact with love.graphics state in non-uniform ways and are handled separately
|
|
--- from the core draw commands (background/image/theme/borders).
|
|
---@param cmd table Command table
|
|
---@param ctx table Resolved draw context
|
|
function Renderer:_executeSpecialCommand(cmd, ctx)
|
|
if cmd.type == "backdropBlur" then
|
|
if ctx.backdropCanvas then
|
|
local blurInstance = self:getBlurInstance()
|
|
if blurInstance then
|
|
local eid = self._element and self._element.id and self._element.id ~= "" and self._element.id or nil
|
|
blurInstance:applyBackdropCached(
|
|
cmd.radius,
|
|
ctx.x,
|
|
ctx.y,
|
|
ctx.borderBoxWidth,
|
|
ctx.borderBoxHeight,
|
|
ctx.backdropCanvas,
|
|
eid
|
|
)
|
|
end
|
|
end
|
|
elseif cmd.type == "text" then
|
|
self:drawText(self._element)
|
|
elseif cmd.type == "customDraw" then
|
|
love.graphics.push()
|
|
love.graphics.setColor(1, 1, 1, 1)
|
|
self._element.customDraw(self._element)
|
|
love.graphics.pop()
|
|
end
|
|
end
|
|
|
|
--- Main draw method - renders all visual layers via command buffer.
|
|
---@param element Element The parent Element instance
|
|
---@param backdropCanvas table|nil Backdrop canvas for backdrop blur
|
|
function Renderer:draw(element, backdropCanvas)
|
|
self._element = element -- cache for customDraw/pressedState
|
|
|
|
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 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 element.opacity ~= nil and element.opacity <= 0 then
|
|
if Renderer._Performance and Renderer._Performance.enabled and elementId then
|
|
Renderer._Performance:stopTimer("render_" .. elementId)
|
|
end
|
|
return
|
|
end
|
|
|
|
-- Build command buffer + resolve draw context once
|
|
local cmds, ctx = self:_buildCommands(element, backdropCanvas)
|
|
|
|
-- 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
|
|
|
|
-- Execute all commands in order
|
|
for _, cmd in ipairs(cmds) do
|
|
-- Draw commands (background, image, theme, borders) use the core executor;
|
|
-- special commands (backdropBlur, customDraw, pressedState) are handled in _executeCommand.
|
|
local typ = cmd.type
|
|
if typ == "background" or typ == "image" or typ == "theme" or typ == "borders" then
|
|
self:_executeDrawCommand(cmd, ctx)
|
|
else
|
|
self:_executeSpecialCommand(cmd, ctx)
|
|
end
|
|
end
|
|
|
|
-- 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
|
|
|
|
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 _, 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
|
|
|
|
-- 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
|
|
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 textColorOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1)
|
|
local textColorWithOpacity = self._Color.new(textColor.r, textColor.g, textColor.b, textColor.a * textColorOpacity)
|
|
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
|
|
|
|
-- Resolve horizontal and vertical alignment (new format with backward compatibility)
|
|
local hAlign = element.textAlignHorizontal or element.textAlign or self._TextAlign.START
|
|
local vAlign = element.textAlignVertical or self._TextAlignVertical.START
|
|
|
|
-- 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 (horizontal alignment only)
|
|
local align = "left"
|
|
if hAlign == self._TextAlign.CENTER then
|
|
align = "center"
|
|
elseif hAlign == self._TextAlign.END then
|
|
align = "right"
|
|
elseif hAlign == 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
|
|
-- Horizontal alignment
|
|
if hAlign == self._TextAlign.START then
|
|
tx = contentX
|
|
elseif hAlign == self._TextAlign.CENTER then
|
|
tx = contentX + (textAreaWidth - textWidth) / 2
|
|
elseif hAlign == self._TextAlign.END then
|
|
tx = contentX + textAreaWidth - textWidth - 10
|
|
else -- JUSTIFY or unknown
|
|
tx = contentX
|
|
end
|
|
|
|
-- Vertical alignment
|
|
if vAlign == self._TextAlignVertical.START then
|
|
ty = contentY
|
|
elseif vAlign == self._TextAlignVertical.CENTER then
|
|
ty = contentY + (textAreaHeight - textHeight) / 2
|
|
elseif vAlign == self._TextAlignVertical.END then
|
|
ty = contentY + textAreaHeight - textHeight
|
|
else
|
|
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 elemOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1)
|
|
local cursorWithOpacity =
|
|
self._Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * elemOpacity)
|
|
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() then
|
|
-- For editable elements, check TextEditor buffer instead of element.text
|
|
local textBuffer = element._textEditor:getText()
|
|
if textBuffer and textBuffer ~= "" then
|
|
local selStart, selEnd = element._textEditor:getSelection()
|
|
local selectionColor = element.selectionColor or self._Color.new(0.3, 0.5, 0.8, 0.5)
|
|
local elemOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1)
|
|
local selectionWithOpacity =
|
|
self._Color.new(selectionColor.r, selectionColor.g, selectionColor.b, selectionColor.a * elemOpacity)
|
|
|
|
-- 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
|
|
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 elemOpacity = element.opacity ~= nil and element.opacity or (self.opacity or 1)
|
|
local cursorWithOpacity = self._Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * elemOpacity)
|
|
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)
|
|
-- Try to get themed scrollbar component
|
|
local scrollbarComponent = nil
|
|
if element.scrollBarStyle or self._Theme.hasActive() then
|
|
scrollbarComponent = self._Theme.getScrollbar(element.scrollBarStyle)
|
|
end
|
|
|
|
-- 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
|
|
|
|
-- Check if we should use themed rendering
|
|
if scrollbarComponent then
|
|
-- Themed scrollbar rendering using NinePatch
|
|
local frameComponent = scrollbarComponent.frame or scrollbarComponent
|
|
local barComponent = scrollbarComponent.bar or scrollbarComponent
|
|
|
|
-- Calculate knob offset (element overrides theme)
|
|
local knobOffsetX = 0
|
|
local knobOffsetY = 0
|
|
|
|
-- Use element offset if provided, otherwise use theme offset
|
|
if element.scrollbarKnobOffset then
|
|
knobOffsetX = element.scrollbarKnobOffset.x or 0
|
|
knobOffsetY = element.scrollbarKnobOffset.vertical or 0
|
|
elseif barComponent and barComponent.knobOffset then
|
|
local themeOffset = self._utils.normalizeOffsetTable(barComponent.knobOffset, 0)
|
|
knobOffsetX = themeOffset.x
|
|
knobOffsetY = themeOffset.vertical
|
|
end
|
|
|
|
-- Extract contentPadding top inset from frame for knob sizing.
|
|
-- Vertical scrollbar only consumes framePaddingTop; other insets are unused.
|
|
local framePaddingTop = 0
|
|
if frameComponent and frameComponent._ninePatchData and frameComponent._ninePatchData.contentPadding then
|
|
framePaddingTop = frameComponent._ninePatchData.contentPadding.top or 0
|
|
end
|
|
|
|
-- Draw track (frame) if component exists
|
|
if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then
|
|
self._NinePatch.draw(
|
|
frameComponent,
|
|
frameComponent._loadedAtlas,
|
|
trackX,
|
|
trackY,
|
|
element.scrollbarWidth,
|
|
dims.vertical.trackHeight
|
|
)
|
|
end
|
|
|
|
-- Draw thumb (bar) if component exists
|
|
if barComponent and barComponent._loadedAtlas and barComponent.regions then
|
|
-- Adjust knob dimensions to account for frame's contentPadding
|
|
-- Vertical scrollbar: width affected by left+right, height affected by top+bottom
|
|
local knobWidth = element.scrollbarWidth
|
|
local knobHeight = dims.vertical.thumbHeight - framePaddingTop / 2
|
|
self._NinePatch.draw(
|
|
barComponent,
|
|
barComponent._loadedAtlas,
|
|
trackX + knobOffsetX,
|
|
trackY + dims.vertical.thumbY + knobOffsetY,
|
|
knobWidth,
|
|
knobHeight
|
|
)
|
|
end
|
|
else
|
|
-- Fallback to color-based rendering
|
|
-- 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
|
|
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
|
|
|
|
-- Check if we should use themed rendering
|
|
if scrollbarComponent then
|
|
-- Themed scrollbar rendering using NinePatch
|
|
local frameComponent = scrollbarComponent.frame or scrollbarComponent
|
|
local barComponent = scrollbarComponent.bar or scrollbarComponent
|
|
|
|
-- Calculate knob offset (element overrides theme)
|
|
local knobOffsetX = 0
|
|
local knobOffsetY = 0
|
|
|
|
-- Use element offset if provided, otherwise use theme offset
|
|
if element.scrollbarKnobOffset then
|
|
knobOffsetX = element.scrollbarKnobOffset.horizontal or 0
|
|
knobOffsetY = element.scrollbarKnobOffset.y or 0
|
|
elseif barComponent and barComponent.knobOffset then
|
|
local themeOffset = self._utils.normalizeOffsetTable(barComponent.knobOffset, 0)
|
|
knobOffsetX = themeOffset.horizontal
|
|
knobOffsetY = themeOffset.y
|
|
end
|
|
|
|
-- Extract contentPadding from frame for knob sizing (horizontal: right inset unused).
|
|
local framePaddingLeft = 0
|
|
local framePaddingTop = 0
|
|
local framePaddingBottom = 0
|
|
if frameComponent and frameComponent._ninePatchData and frameComponent._ninePatchData.contentPadding then
|
|
framePaddingLeft = frameComponent._ninePatchData.contentPadding.left or 0
|
|
framePaddingTop = frameComponent._ninePatchData.contentPadding.top or 0
|
|
framePaddingBottom = frameComponent._ninePatchData.contentPadding.bottom or 0
|
|
end
|
|
|
|
-- Draw track (frame) if component exists
|
|
if frameComponent and frameComponent._loadedAtlas and frameComponent.regions then
|
|
self._NinePatch.draw(
|
|
frameComponent,
|
|
frameComponent._loadedAtlas,
|
|
trackX,
|
|
trackY,
|
|
dims.horizontal.trackWidth,
|
|
element.scrollbarWidth
|
|
)
|
|
end
|
|
|
|
-- Draw thumb (bar) if component exists
|
|
if barComponent and barComponent._loadedAtlas and barComponent.regions then
|
|
-- Adjust knob dimensions to account for frame's contentPadding
|
|
-- Horizontal scrollbar: width affected by left+right, height affected by top+bottom
|
|
local knobWidth = dims.horizontal.thumbWidth - framePaddingLeft / 2
|
|
local knobHeight = element.scrollbarWidth - framePaddingTop - framePaddingBottom
|
|
self._NinePatch.draw(
|
|
barComponent,
|
|
barComponent._loadedAtlas,
|
|
trackX + dims.horizontal.thumbX + knobOffsetX,
|
|
trackY + knobOffsetY,
|
|
knobWidth,
|
|
knobHeight
|
|
)
|
|
end
|
|
else
|
|
-- Fallback to color-based rendering
|
|
-- 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
|
|
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
|
|
---@param opacity number Element opacity
|
|
---@param cornerRadius number|table Corner radius
|
|
function Renderer:drawPressedState(x, y, borderBoxWidth, borderBoxHeight, opacity, cornerRadius)
|
|
love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * (opacity or 1))
|
|
self._RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, cornerRadius)
|
|
end
|
|
|
|
--- Cleanup renderer resources
|
|
function Renderer:destroy()
|
|
self._loadedImage = nil
|
|
self._blurInstance = nil
|
|
end
|
|
|
|
return Renderer
|