renderer extraction started - complicated
This commit is contained in:
@@ -25,6 +25,7 @@ local InputEvent = req("InputEvent")
|
|||||||
local StateManager = req("StateManager")
|
local StateManager = req("StateManager")
|
||||||
local TextEditor = req("TextEditor")
|
local TextEditor = req("TextEditor")
|
||||||
local LayoutEngine = req("LayoutEngine")
|
local LayoutEngine = req("LayoutEngine")
|
||||||
|
local Renderer = req("Renderer")
|
||||||
|
|
||||||
-- Extract utilities
|
-- Extract utilities
|
||||||
local enums = utils.enums
|
local enums = utils.enums
|
||||||
@@ -438,6 +439,29 @@ function Element.new(props)
|
|||||||
self._loadedImage = nil
|
self._loadedImage = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- Initialize Renderer module for visual rendering
|
||||||
|
self._renderer = Renderer.new({
|
||||||
|
backgroundColor = self.backgroundColor,
|
||||||
|
borderColor = self.borderColor,
|
||||||
|
opacity = self.opacity,
|
||||||
|
border = self.border,
|
||||||
|
cornerRadius = self.cornerRadius,
|
||||||
|
theme = self.theme,
|
||||||
|
themeComponent = self.themeComponent,
|
||||||
|
scaleCorners = self.scaleCorners,
|
||||||
|
scalingAlgorithm = self.scalingAlgorithm,
|
||||||
|
imagePath = self.imagePath,
|
||||||
|
image = self.image,
|
||||||
|
_loadedImage = self._loadedImage,
|
||||||
|
objectFit = self.objectFit,
|
||||||
|
objectPosition = self.objectPosition,
|
||||||
|
imageOpacity = self.imageOpacity,
|
||||||
|
contentBlur = self.contentBlur,
|
||||||
|
backdropBlur = self.backdropBlur,
|
||||||
|
_themeState = self._themeState,
|
||||||
|
})
|
||||||
|
self._renderer:initialize(self)
|
||||||
|
|
||||||
--- self positioning ---
|
--- self positioning ---
|
||||||
local viewportWidth, viewportHeight = Units.getViewport()
|
local viewportWidth, viewportHeight = Units.getViewport()
|
||||||
|
|
||||||
@@ -1437,68 +1461,6 @@ function Element:_calculateScrollbarDimensions()
|
|||||||
end
|
end
|
||||||
|
|
||||||
--- Draw scrollbars
|
--- Draw scrollbars
|
||||||
---@param dims table -- Scrollbar dimensions from _calculateScrollbarDimensions()
|
|
||||||
function Element:_drawScrollbars(dims)
|
|
||||||
local x, y = self.x, self.y
|
|
||||||
local w, h = self.width, self.height
|
|
||||||
|
|
||||||
-- Vertical scrollbar
|
|
||||||
if dims.vertical.visible and not self.hideScrollbars.vertical then
|
|
||||||
-- Position scrollbar within content area (x, y is border-box origin)
|
|
||||||
local contentX = x + self.padding.left
|
|
||||||
local contentY = y + self.padding.top
|
|
||||||
local trackX = contentX + w - self.scrollbarWidth - self.scrollbarPadding
|
|
||||||
local trackY = contentY + self.scrollbarPadding
|
|
||||||
|
|
||||||
-- Determine thumb color based on state (independent for vertical)
|
|
||||||
local thumbColor = self.scrollbarColor
|
|
||||||
if self._scrollbarDragging and self._hoveredScrollbar == "vertical" then
|
|
||||||
-- Active state: brighter
|
|
||||||
thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a)
|
|
||||||
elseif self._scrollbarHoveredVertical then
|
|
||||||
-- Hover state: slightly brighter
|
|
||||||
thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Draw track
|
|
||||||
love.graphics.setColor(self.scrollbarTrackColor:toRGBA())
|
|
||||||
love.graphics.rectangle("fill", trackX, trackY, self.scrollbarWidth, dims.vertical.trackHeight, self.scrollbarRadius)
|
|
||||||
|
|
||||||
-- Draw thumb with state-based color
|
|
||||||
love.graphics.setColor(thumbColor:toRGBA())
|
|
||||||
love.graphics.rectangle("fill", trackX, trackY + dims.vertical.thumbY, self.scrollbarWidth, dims.vertical.thumbHeight, self.scrollbarRadius)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Horizontal scrollbar
|
|
||||||
if dims.horizontal.visible and not self.hideScrollbars.horizontal then
|
|
||||||
-- Position scrollbar within content area (x, y is border-box origin)
|
|
||||||
local contentX = x + self.padding.left
|
|
||||||
local contentY = y + self.padding.top
|
|
||||||
local trackX = contentX + self.scrollbarPadding
|
|
||||||
local trackY = contentY + h - self.scrollbarWidth - self.scrollbarPadding
|
|
||||||
|
|
||||||
-- Determine thumb color based on state (independent for horizontal)
|
|
||||||
local thumbColor = self.scrollbarColor
|
|
||||||
if self._scrollbarDragging and self._hoveredScrollbar == "horizontal" then
|
|
||||||
-- Active state: brighter
|
|
||||||
thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a)
|
|
||||||
elseif self._scrollbarHoveredHorizontal then
|
|
||||||
-- Hover state: slightly brighter
|
|
||||||
thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Draw track
|
|
||||||
love.graphics.setColor(self.scrollbarTrackColor:toRGBA())
|
|
||||||
love.graphics.rectangle("fill", trackX, trackY, dims.horizontal.trackWidth, self.scrollbarWidth, self.scrollbarRadius)
|
|
||||||
|
|
||||||
-- Draw thumb with state-based color
|
|
||||||
love.graphics.setColor(thumbColor:toRGBA())
|
|
||||||
love.graphics.rectangle("fill", trackX + dims.horizontal.thumbX, trackY, dims.horizontal.thumbWidth, self.scrollbarWidth, self.scrollbarRadius)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Reset color
|
|
||||||
love.graphics.setColor(1, 1, 1, 1)
|
|
||||||
end
|
|
||||||
|
|
||||||
--- Get scrollbar at mouse position
|
--- Get scrollbar at mouse position
|
||||||
---@param mouseX number
|
---@param mouseX number
|
||||||
@@ -2092,390 +2054,11 @@ function Element:draw(backdropCanvas)
|
|||||||
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
|
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
|
||||||
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
||||||
|
|
||||||
-- LAYER 0.5: Draw backdrop blur if configured (before background)
|
-- LAYERS 0.5-3: Delegate visual rendering (backdrop blur, background, image, theme, borders) to Renderer module
|
||||||
if self.backdropBlur and self.backdropBlur.intensity > 0 and backdropCanvas then
|
self._renderer:draw(backdropCanvas)
|
||||||
local blurInstance = self:getBlurInstance()
|
|
||||||
if blurInstance then
|
|
||||||
Blur.applyBackdrop(blurInstance, self.backdropBlur.intensity, self.x, self.y, borderBoxWidth, borderBoxHeight, backdropCanvas)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- LAYER 1: Draw backgroundColor first (behind everything)
|
-- LAYER 4: Delegate text rendering (text, cursor, selection, placeholder, password masking) to Renderer module
|
||||||
-- Apply opacity to all drawing operations
|
self._renderer:drawText(self)
|
||||||
-- (x, y) represents border box, so draw background from (x, y)
|
|
||||||
-- BORDER-BOX MODEL: Use stored border-box dimensions for drawing
|
|
||||||
local backgroundWithOpacity = Color.new(drawBackgroundColor.r, drawBackgroundColor.g, drawBackgroundColor.b, drawBackgroundColor.a * self.opacity)
|
|
||||||
love.graphics.setColor(backgroundWithOpacity:toRGBA())
|
|
||||||
RoundedRect.draw("fill", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
|
|
||||||
|
|
||||||
-- LAYER 1.5: Draw image on top of backgroundColor (if image exists)
|
|
||||||
if self._loadedImage then
|
|
||||||
-- Calculate image bounds (content area - respects padding)
|
|
||||||
local imageX = self.x + self.padding.left
|
|
||||||
local imageY = self.y + self.padding.top
|
|
||||||
local imageWidth = self.width
|
|
||||||
local imageHeight = self.height
|
|
||||||
|
|
||||||
-- Combine element opacity with imageOpacity
|
|
||||||
local finalOpacity = self.opacity * self.imageOpacity
|
|
||||||
|
|
||||||
-- Apply cornerRadius clipping if set
|
|
||||||
local hasCornerRadius = self.cornerRadius.topLeft > 0
|
|
||||||
or self.cornerRadius.topRight > 0
|
|
||||||
or self.cornerRadius.bottomLeft > 0
|
|
||||||
or self.cornerRadius.bottomRight > 0
|
|
||||||
|
|
||||||
if hasCornerRadius then
|
|
||||||
-- Use stencil to clip image to rounded corners
|
|
||||||
love.graphics.stencil(function()
|
|
||||||
RoundedRect.draw("fill", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
|
|
||||||
end, "replace", 1)
|
|
||||||
love.graphics.setStencilTest("greater", 0)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Draw the image
|
|
||||||
ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity)
|
|
||||||
|
|
||||||
-- Clear stencil if it was used
|
|
||||||
if hasCornerRadius then
|
|
||||||
love.graphics.setStencilTest()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- LAYER 2: Draw theme on top of backgroundColor (if theme exists)
|
|
||||||
if self.themeComponent then
|
|
||||||
-- Get the theme to use
|
|
||||||
local themeToUse = nil
|
|
||||||
if self.theme then
|
|
||||||
-- Element specifies a specific theme - load it if needed
|
|
||||||
if Theme.get(self.theme) then
|
|
||||||
themeToUse = Theme.get(self.theme)
|
|
||||||
else
|
|
||||||
-- Try to load the theme
|
|
||||||
pcall(function()
|
|
||||||
Theme.load(self.theme)
|
|
||||||
end)
|
|
||||||
themeToUse = Theme.get(self.theme)
|
|
||||||
end
|
|
||||||
else
|
|
||||||
-- Use active theme
|
|
||||||
themeToUse = Theme.getActive()
|
|
||||||
end
|
|
||||||
|
|
||||||
if themeToUse then
|
|
||||||
-- Get the component from the theme
|
|
||||||
local component = themeToUse.components[self.themeComponent]
|
|
||||||
if component then
|
|
||||||
-- Check for state-specific override
|
|
||||||
local state = self._themeState
|
|
||||||
if state and component.states and component.states[state] then
|
|
||||||
component = component.states[state]
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Use component-specific atlas if available, otherwise use theme atlas
|
|
||||||
local atlasToUse = component._loadedAtlas or themeToUse.atlas
|
|
||||||
|
|
||||||
if atlasToUse 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
|
|
||||||
-- Calculate border-box dimensions (content + padding)
|
|
||||||
local borderBoxWidth = self.width + self.padding.left + self.padding.right
|
|
||||||
local borderBoxHeight = self.height + self.padding.top + self.padding.bottom
|
|
||||||
-- Pass element-level overrides for scaleCorners and scalingAlgorithm
|
|
||||||
NinePatch.draw(component, atlasToUse, self.x, self.y, borderBoxWidth, borderBoxHeight, self.opacity, self.scaleCorners, self.scalingAlgorithm)
|
|
||||||
else
|
|
||||||
-- Silently skip drawing if component structure is invalid
|
|
||||||
end
|
|
||||||
end
|
|
||||||
else
|
|
||||||
-- Component not found in theme
|
|
||||||
end
|
|
||||||
else
|
|
||||||
-- No theme available for themeComponent
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- LAYER 3: Draw borders on top of theme (always render if specified)
|
|
||||||
local borderColorWithOpacity = Color.new(self.borderColor.r, self.borderColor.g, self.borderColor.b, self.borderColor.a * self.opacity)
|
|
||||||
love.graphics.setColor(borderColorWithOpacity:toRGBA())
|
|
||||||
|
|
||||||
-- Check if all borders are enabled
|
|
||||||
local allBorders = self.border.top and self.border.bottom and self.border.left and self.border.right
|
|
||||||
|
|
||||||
if allBorders then
|
|
||||||
-- Draw complete rounded rectangle border
|
|
||||||
RoundedRect.draw("line", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
|
|
||||||
else
|
|
||||||
-- Draw individual borders (without rounded corners for partial borders)
|
|
||||||
if self.border.top then
|
|
||||||
love.graphics.line(self.x, self.y, self.x + borderBoxWidth, self.y)
|
|
||||||
end
|
|
||||||
if self.border.bottom then
|
|
||||||
love.graphics.line(self.x, self.y + borderBoxHeight, self.x + borderBoxWidth, self.y + borderBoxHeight)
|
|
||||||
end
|
|
||||||
if self.border.left then
|
|
||||||
love.graphics.line(self.x, self.y, self.x, self.y + borderBoxHeight)
|
|
||||||
end
|
|
||||||
if self.border.right then
|
|
||||||
love.graphics.line(self.x + borderBoxWidth, self.y, self.x + borderBoxWidth, self.y + borderBoxHeight)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Draw element text if present
|
|
||||||
-- For editable elements, also handle placeholder
|
|
||||||
-- Update text layout if dirty (for multiline auto-grow)
|
|
||||||
if self._textEditor then
|
|
||||||
self._textEditor:_updateTextIfDirty()
|
|
||||||
self._textEditor:updateAutoGrowHeight()
|
|
||||||
end
|
|
||||||
|
|
||||||
-- For editable elements, use TextEditor buffer; for non-editable, use text
|
|
||||||
local displayText = self._textEditor and self._textEditor:getText() or self.text
|
|
||||||
local isPlaceholder = false
|
|
||||||
local isPasswordMasked = false
|
|
||||||
|
|
||||||
-- Show placeholder if editable and empty
|
|
||||||
if self.editable and (not displayText or displayText == "") and self.placeholder then
|
|
||||||
displayText = self.placeholder
|
|
||||||
isPlaceholder = true
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Apply password masking if enabled
|
|
||||||
if self.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 Color.new(self.textColor.r * 0.5, self.textColor.g * 0.5, self.textColor.b * 0.5, self.textColor.a * 0.5)
|
|
||||||
or self.textColor
|
|
||||||
local textColorWithOpacity = Color.new(textColor.r, textColor.g, textColor.b, textColor.a * self.opacity)
|
|
||||||
love.graphics.setColor(textColorWithOpacity:toRGBA())
|
|
||||||
|
|
||||||
local origFont = love.graphics.getFont()
|
|
||||||
if self.textSize then
|
|
||||||
-- Resolve font path from font family
|
|
||||||
local fontPath = nil
|
|
||||||
if self.fontFamily then
|
|
||||||
-- Check if fontFamily is a theme font name
|
|
||||||
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive()
|
|
||||||
if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then
|
|
||||||
fontPath = themeToUse.fonts[self.fontFamily]
|
|
||||||
else
|
|
||||||
-- Treat as direct path to font file
|
|
||||||
fontPath = self.fontFamily
|
|
||||||
end
|
|
||||||
elseif self.themeComponent then
|
|
||||||
-- If using themeComponent but no fontFamily specified, check for default font in theme
|
|
||||||
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive()
|
|
||||||
if themeToUse and themeToUse.fonts and themeToUse.fonts.default then
|
|
||||||
fontPath = themeToUse.fonts.default
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Use cached font instead of creating new one every frame
|
|
||||||
local font = FONT_CACHE.get(self.textSize, fontPath)
|
|
||||||
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 = self.padding.left
|
|
||||||
local textPaddingTop = self.padding.top
|
|
||||||
local textAreaWidth = self.width
|
|
||||||
local textAreaHeight = self.height
|
|
||||||
|
|
||||||
-- Check if we should use 9-patch contentPadding for text positioning
|
|
||||||
local scaledContentPadding = self:getScaledContentPadding()
|
|
||||||
if scaledContentPadding then
|
|
||||||
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
|
|
||||||
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
|
||||||
|
|
||||||
textPaddingLeft = scaledContentPadding.left
|
|
||||||
textPaddingTop = scaledContentPadding.top
|
|
||||||
textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right
|
|
||||||
textAreaHeight = borderBoxHeight - scaledContentPadding.top - scaledContentPadding.bottom
|
|
||||||
end
|
|
||||||
|
|
||||||
local contentX = self.x + textPaddingLeft
|
|
||||||
local contentY = self.y + textPaddingTop
|
|
||||||
|
|
||||||
-- Check if text wrapping is enabled
|
|
||||||
if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then
|
|
||||||
-- Use printf for wrapped text
|
|
||||||
local align = "left"
|
|
||||||
if self.textAlign == TextAlign.CENTER then
|
|
||||||
align = "center"
|
|
||||||
elseif self.textAlign == TextAlign.END then
|
|
||||||
align = "right"
|
|
||||||
elseif self.textAlign == 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 self.textAlign == TextAlign.START then
|
|
||||||
tx = contentX
|
|
||||||
ty = contentY
|
|
||||||
elseif self.textAlign == TextAlign.CENTER then
|
|
||||||
tx = contentX + (textAreaWidth - textWidth) / 2
|
|
||||||
ty = contentY + (textAreaHeight - textHeight) / 2
|
|
||||||
elseif self.textAlign == TextAlign.END then
|
|
||||||
tx = contentX + textAreaWidth - textWidth - 10
|
|
||||||
ty = contentY + textAreaHeight - textHeight - 10
|
|
||||||
elseif self.textAlign == TextAlign.JUSTIFY then
|
|
||||||
--- need to figure out spreading
|
|
||||||
tx = contentX
|
|
||||||
ty = contentY
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Apply scroll offset for editable single-line inputs
|
|
||||||
if self.editable and not self.multiline and self._textScrollX then
|
|
||||||
tx = tx - self._textScrollX
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Use scissor to clip text to content area for editable inputs
|
|
||||||
if self.editable and not self.multiline then
|
|
||||||
love.graphics.setScissor(contentX, contentY, textAreaWidth, textAreaHeight)
|
|
||||||
end
|
|
||||||
|
|
||||||
love.graphics.print(displayText, tx, ty)
|
|
||||||
|
|
||||||
-- Reset scissor
|
|
||||||
if self.editable and not self.multiline then
|
|
||||||
love.graphics.setScissor()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Draw cursor for focused editable elements (even if text is empty)
|
|
||||||
if self._textEditor and self._textEditor:isFocused() and self._textEditor._cursorVisible then
|
|
||||||
local cursorColor = self.cursorColor or self.textColor
|
|
||||||
local cursorWithOpacity = 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 = self._textEditor:_getCursorScreenPosition()
|
|
||||||
local cursorX = contentX + cursorRelX
|
|
||||||
local cursorY = contentY + cursorRelY
|
|
||||||
local cursorHeight = textHeight
|
|
||||||
|
|
||||||
-- Apply scroll offset for single-line inputs
|
|
||||||
if not self.multiline and self._textEditor._textScrollX then
|
|
||||||
cursorX = cursorX - self._textEditor._textScrollX
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Apply scissor for single-line editable inputs
|
|
||||||
if not self.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 self.multiline then
|
|
||||||
love.graphics.setScissor()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Draw selection highlight for editable elements
|
|
||||||
if self._textEditor and self._textEditor:isFocused() and self._textEditor:hasSelection() and self.text and self.text ~= "" then
|
|
||||||
local selStart, selEnd = self._textEditor:getSelection()
|
|
||||||
local selectionColor = self.selectionColor or Color.new(0.3, 0.5, 0.8, 0.5)
|
|
||||||
local selectionWithOpacity = Color.new(selectionColor.r, selectionColor.g, selectionColor.b, selectionColor.a * self.opacity)
|
|
||||||
|
|
||||||
-- Get selection rectangles from TextEditor
|
|
||||||
local selectionRects = self._textEditor:_getSelectionRects(selStart, selEnd)
|
|
||||||
|
|
||||||
-- Apply scissor for single-line editable inputs
|
|
||||||
if not self.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 self.multiline and self._textEditor._textScrollX then
|
|
||||||
rectX = rectX - self._textEditor._textScrollX
|
|
||||||
end
|
|
||||||
love.graphics.rectangle("fill", rectX, rectY, rect.width, rect.height)
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Reset scissor
|
|
||||||
if not self.multiline then
|
|
||||||
love.graphics.setScissor()
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
if self.textSize then
|
|
||||||
love.graphics.setFont(origFont)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Draw cursor for focused editable elements even when empty
|
|
||||||
if self._textEditor and self._textEditor:isFocused() and self._textEditor._cursorVisible and (not displayText or displayText == "") then
|
|
||||||
-- Set up font for cursor rendering
|
|
||||||
local origFont = love.graphics.getFont()
|
|
||||||
if self.textSize then
|
|
||||||
local fontPath = nil
|
|
||||||
if self.fontFamily then
|
|
||||||
local themeToUse = self.theme and Theme.get(self.theme) or Theme.getActive()
|
|
||||||
if themeToUse and themeToUse.fonts and themeToUse.fonts[self.fontFamily] then
|
|
||||||
fontPath = themeToUse.fonts[self.fontFamily]
|
|
||||||
else
|
|
||||||
fontPath = self.fontFamily
|
|
||||||
end
|
|
||||||
end
|
|
||||||
local font = FONT_CACHE.get(self.textSize, fontPath)
|
|
||||||
love.graphics.setFont(font)
|
|
||||||
end
|
|
||||||
|
|
||||||
local font = love.graphics.getFont()
|
|
||||||
local textHeight = font:getHeight()
|
|
||||||
|
|
||||||
-- Calculate text area position
|
|
||||||
local textPaddingLeft = self.padding.left
|
|
||||||
local textPaddingTop = self.padding.top
|
|
||||||
local scaledContentPadding = self:getScaledContentPadding()
|
|
||||||
if scaledContentPadding then
|
|
||||||
textPaddingLeft = scaledContentPadding.left
|
|
||||||
textPaddingTop = scaledContentPadding.top
|
|
||||||
end
|
|
||||||
|
|
||||||
local contentX = self.x + textPaddingLeft
|
|
||||||
local contentY = self.y + textPaddingTop
|
|
||||||
|
|
||||||
-- Draw cursor
|
|
||||||
local cursorColor = self.cursorColor or self.textColor
|
|
||||||
local cursorWithOpacity = 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 self.textSize then
|
|
||||||
love.graphics.setFont(origFont)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- Draw visual feedback when element is pressed (if it has an onEvent handler and highlight is not disabled)
|
-- Draw visual feedback when element is pressed (if it has an onEvent handler and highlight is not disabled)
|
||||||
if self.onEvent and not self.disableHighlight then
|
if self.onEvent and not self.disableHighlight then
|
||||||
@@ -2491,8 +2074,7 @@ function Element:draw(backdropCanvas)
|
|||||||
-- BORDER-BOX MODEL: Use stored border-box dimensions for drawing
|
-- BORDER-BOX MODEL: Use stored border-box dimensions for drawing
|
||||||
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
|
local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right)
|
||||||
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
local borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom)
|
||||||
love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * self.opacity) -- Semi-transparent gray for pressed state with opacity
|
self._renderer:drawPressedState(self.x, self.y, borderBoxWidth, borderBoxHeight)
|
||||||
RoundedRect.draw("fill", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -2605,7 +2187,8 @@ function Element:draw(backdropCanvas)
|
|||||||
if scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible then
|
if scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible then
|
||||||
-- Clear any parent scissor clipping before drawing scrollbars
|
-- Clear any parent scissor clipping before drawing scrollbars
|
||||||
love.graphics.setScissor()
|
love.graphics.setScissor()
|
||||||
self:_drawScrollbars(scrollbarDims)
|
-- Delegate scrollbar rendering to Renderer module
|
||||||
|
self._renderer:drawScrollbars(self, self.x, self.y, self.width, self.height, scrollbarDims)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -2818,6 +2401,10 @@ function Element:update(dt)
|
|||||||
|
|
||||||
-- Always update local state for backward compatibility
|
-- Always update local state for backward compatibility
|
||||||
self._themeState = newThemeState
|
self._themeState = newThemeState
|
||||||
|
-- Sync theme state with Renderer module
|
||||||
|
if self._renderer then
|
||||||
|
self._renderer:setThemeState(newThemeState)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Only process button events if onEvent handler exists, element is not disabled,
|
-- Only process button events if onEvent handler exists, element is not disabled,
|
||||||
|
|||||||
686
modules/Renderer.lua
Normal file
686
modules/Renderer.lua
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
--- Renderer Module
|
||||||
|
-- Handles all visual rendering for Elements including backgrounds, borders,
|
||||||
|
-- images, themes, blur effects, and text rendering.
|
||||||
|
--
|
||||||
|
-- This module is responsible for the visual presentation layer of Elements,
|
||||||
|
-- delegating from Element's draw() method to keep rendering concerns separated.
|
||||||
|
|
||||||
|
-- Setup module path for relative requires
|
||||||
|
local modulePath = (...):match("(.-)[^%.]+$")
|
||||||
|
local function req(name)
|
||||||
|
return require(modulePath .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
local Renderer = {}
|
||||||
|
Renderer.__index = Renderer
|
||||||
|
|
||||||
|
-- Dependencies
|
||||||
|
local Color = req("Color")
|
||||||
|
local RoundedRect = req("RoundedRect")
|
||||||
|
local NinePatch = req("NinePatch")
|
||||||
|
local ImageRenderer = req("ImageRenderer")
|
||||||
|
local ImageCache = req("ImageCache")
|
||||||
|
local Theme = req("Theme")
|
||||||
|
local Blur = req("Blur")
|
||||||
|
local utils = req("utils")
|
||||||
|
|
||||||
|
-- Font cache and enums (shared with Element for now - could be refactored later)
|
||||||
|
local FONT_CACHE = utils.FONT_CACHE
|
||||||
|
local enums = utils.enums
|
||||||
|
local TextAlign = enums.TextAlign
|
||||||
|
|
||||||
|
--- Create a new Renderer instance
|
||||||
|
---@param config table Configuration table with rendering properties
|
||||||
|
---@return table Renderer instance
|
||||||
|
function Renderer.new(config)
|
||||||
|
local self = setmetatable({}, Renderer)
|
||||||
|
|
||||||
|
-- Store reference to parent element (will be set via initialize)
|
||||||
|
self._element = nil
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
|
||||||
|
--- Initialize renderer with parent element reference
|
||||||
|
---@param element table The parent Element instance
|
||||||
|
function Renderer:initialize(element)
|
||||||
|
self._element = element
|
||||||
|
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
|
||||||
|
|
||||||
|
-- Create or reuse blur instance
|
||||||
|
if not self._blurInstance or self._blurInstance.quality ~= quality then
|
||||||
|
self._blurInstance = Blur.new(quality)
|
||||||
|
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 = Color.new(
|
||||||
|
drawBackgroundColor.r,
|
||||||
|
drawBackgroundColor.g,
|
||||||
|
drawBackgroundColor.b,
|
||||||
|
drawBackgroundColor.a * self.opacity
|
||||||
|
)
|
||||||
|
love.graphics.setColor(backgroundWithOpacity:toRGBA())
|
||||||
|
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 = self.cornerRadius.topLeft > 0
|
||||||
|
or self.cornerRadius.topRight > 0
|
||||||
|
or self.cornerRadius.bottomLeft > 0
|
||||||
|
or self.cornerRadius.bottomRight > 0
|
||||||
|
|
||||||
|
if hasCornerRadius then
|
||||||
|
-- Use stencil to clip image to rounded corners
|
||||||
|
love.graphics.stencil(function()
|
||||||
|
RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
|
||||||
|
end, "replace", 1)
|
||||||
|
love.graphics.setStencilTest("greater", 0)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Draw the image
|
||||||
|
ImageRenderer.draw(self._loadedImage, imageX, imageY, imageWidth, imageHeight, self.objectFit, self.objectPosition, finalOpacity)
|
||||||
|
|
||||||
|
-- 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 Theme.get(self.theme) then
|
||||||
|
themeToUse = Theme.get(self.theme)
|
||||||
|
else
|
||||||
|
-- Try to load the theme
|
||||||
|
pcall(function()
|
||||||
|
Theme.load(self.theme)
|
||||||
|
end)
|
||||||
|
themeToUse = Theme.get(self.theme)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
-- Use active theme
|
||||||
|
themeToUse = 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
|
||||||
|
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)
|
||||||
|
local borderColorWithOpacity = Color.new(
|
||||||
|
self.borderColor.r,
|
||||||
|
self.borderColor.g,
|
||||||
|
self.borderColor.b,
|
||||||
|
self.borderColor.a * self.opacity
|
||||||
|
)
|
||||||
|
love.graphics.setColor(borderColorWithOpacity:toRGBA())
|
||||||
|
|
||||||
|
-- Check if all borders are enabled
|
||||||
|
local allBorders = self.border.top and self.border.bottom and self.border.left and self.border.right
|
||||||
|
|
||||||
|
if allBorders then
|
||||||
|
-- Draw complete rounded rectangle border
|
||||||
|
RoundedRect.draw("line", 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 backdropCanvas table|nil Backdrop canvas for backdrop blur
|
||||||
|
function Renderer:draw(backdropCanvas)
|
||||||
|
-- Early exit if element is invisible (optimization)
|
||||||
|
if self.opacity <= 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Element must be initialized before drawing
|
||||||
|
if not self._element then
|
||||||
|
error("Renderer:draw() called before initialize(). Call renderer:initialize(element) first.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local element = self._element
|
||||||
|
|
||||||
|
-- Handle opacity during animation
|
||||||
|
local drawBackgroundColor = self.backgroundColor
|
||||||
|
if element.animation then
|
||||||
|
local anim = element.animation:interpolate()
|
||||||
|
if anim.opacity then
|
||||||
|
drawBackgroundColor = 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)
|
||||||
|
|
||||||
|
-- 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
|
||||||
|
Blur.applyBackdrop(blurInstance, self.backdropBlur.intensity, element.x, element.y, borderBoxWidth, borderBoxHeight, backdropCanvas)
|
||||||
|
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)
|
||||||
|
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._textEditor:updateAutoGrowHeight()
|
||||||
|
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 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 = 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
|
||||||
|
-- Resolve font path from font family
|
||||||
|
local fontPath = nil
|
||||||
|
if element.fontFamily then
|
||||||
|
-- Check if fontFamily is a theme font name
|
||||||
|
local themeToUse = element.theme and Theme.get(element.theme) or Theme.getActive()
|
||||||
|
if themeToUse and themeToUse.fonts and themeToUse.fonts[element.fontFamily] then
|
||||||
|
fontPath = themeToUse.fonts[element.fontFamily]
|
||||||
|
else
|
||||||
|
-- Treat as direct path to font file
|
||||||
|
fontPath = element.fontFamily
|
||||||
|
end
|
||||||
|
elseif element.themeComponent then
|
||||||
|
-- If using themeComponent but no fontFamily specified, check for default font in theme
|
||||||
|
local themeToUse = element.theme and Theme.get(element.theme) or Theme.getActive()
|
||||||
|
if themeToUse and themeToUse.fonts and themeToUse.fonts.default then
|
||||||
|
fontPath = themeToUse.fonts.default
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Use cached font instead of creating new one every frame
|
||||||
|
local font = FONT_CACHE.get(element.textSize, fontPath)
|
||||||
|
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 == TextAlign.CENTER then
|
||||||
|
align = "center"
|
||||||
|
elseif element.textAlign == TextAlign.END then
|
||||||
|
align = "right"
|
||||||
|
elseif element.textAlign == 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 == TextAlign.START then
|
||||||
|
tx = contentX
|
||||||
|
ty = contentY
|
||||||
|
elseif element.textAlign == TextAlign.CENTER then
|
||||||
|
tx = contentX + (textAreaWidth - textWidth) / 2
|
||||||
|
ty = contentY + (textAreaHeight - textHeight) / 2
|
||||||
|
elseif element.textAlign == TextAlign.END then
|
||||||
|
tx = contentX + textAreaWidth - textWidth - 10
|
||||||
|
ty = contentY + textAreaHeight - textHeight - 10
|
||||||
|
elseif element.textAlign == 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 = 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()
|
||||||
|
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 Color.new(0.3, 0.5, 0.8, 0.5)
|
||||||
|
local selectionWithOpacity = Color.new(selectionColor.r, selectionColor.g, selectionColor.b, selectionColor.a * self.opacity)
|
||||||
|
|
||||||
|
-- Get selection rectangles from TextEditor
|
||||||
|
local selectionRects = element._textEditor:_getSelectionRects(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 fontPath = nil
|
||||||
|
if element.fontFamily then
|
||||||
|
local themeToUse = element.theme and Theme.get(element.theme) or Theme.getActive()
|
||||||
|
if themeToUse and themeToUse.fonts and themeToUse.fonts[element.fontFamily] then
|
||||||
|
fontPath = themeToUse.fonts[element.fontFamily]
|
||||||
|
else
|
||||||
|
fontPath = element.fontFamily
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local font = FONT_CACHE.get(element.textSize, fontPath)
|
||||||
|
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 = 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
|
||||||
|
thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a)
|
||||||
|
elseif element._scrollbarHoveredVertical then
|
||||||
|
-- Hover state: slightly brighter
|
||||||
|
thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a)
|
||||||
|
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
|
||||||
|
thumbColor = Color.new(math.min(1, thumbColor.r * 1.4), math.min(1, thumbColor.g * 1.4), math.min(1, thumbColor.b * 1.4), thumbColor.a)
|
||||||
|
elseif element._scrollbarHoveredHorizontal then
|
||||||
|
-- Hover state: slightly brighter
|
||||||
|
thumbColor = Color.new(math.min(1, thumbColor.r * 1.2), math.min(1, thumbColor.g * 1.2), math.min(1, thumbColor.b * 1.2), thumbColor.a)
|
||||||
|
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
|
||||||
|
RoundedRect.draw("fill", x, y, borderBoxWidth, borderBoxHeight, self.cornerRadius)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Cleanup renderer resources
|
||||||
|
function Renderer:destroy()
|
||||||
|
self._element = nil
|
||||||
|
self._loadedImage = nil
|
||||||
|
self._blurInstance = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return Renderer
|
||||||
Reference in New Issue
Block a user