From d2f9c70601c809977f5e72eb0b5b7af97304a418 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 12 Nov 2025 19:16:13 -0500 Subject: [PATCH] renderer extraction started - complicated --- modules/Element.lua | 483 +++--------------------------- modules/Renderer.lua | 686 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 721 insertions(+), 448 deletions(-) create mode 100644 modules/Renderer.lua diff --git a/modules/Element.lua b/modules/Element.lua index 440134b..7a5c54d 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -25,6 +25,7 @@ local InputEvent = req("InputEvent") local StateManager = req("StateManager") local TextEditor = req("TextEditor") local LayoutEngine = req("LayoutEngine") +local Renderer = req("Renderer") -- Extract utilities local enums = utils.enums @@ -438,6 +439,29 @@ function Element.new(props) self._loadedImage = nil 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 --- local viewportWidth, viewportHeight = Units.getViewport() @@ -1437,68 +1461,6 @@ function Element:_calculateScrollbarDimensions() end --- 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 ---@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 borderBoxHeight = self._borderBoxHeight or (self.height + self.padding.top + self.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, self.x, self.y, borderBoxWidth, borderBoxHeight, backdropCanvas) - end - end + -- LAYERS 0.5-3: Delegate visual rendering (backdrop blur, background, image, theme, borders) to Renderer module + self._renderer:draw(backdropCanvas) - -- LAYER 1: Draw backgroundColor first (behind everything) - -- Apply opacity to all drawing operations - -- (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 + -- LAYER 4: Delegate text rendering (text, cursor, selection, placeholder, password masking) to Renderer module + self._renderer:drawText(self) -- 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 @@ -2491,8 +2074,7 @@ function Element:draw(backdropCanvas) -- BORDER-BOX MODEL: Use stored border-box dimensions for drawing 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) - love.graphics.setColor(0.5, 0.5, 0.5, 0.3 * self.opacity) -- Semi-transparent gray for pressed state with opacity - RoundedRect.draw("fill", self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) + self._renderer:drawPressedState(self.x, self.y, borderBoxWidth, borderBoxHeight) end end @@ -2605,7 +2187,8 @@ function Element:draw(backdropCanvas) if scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible then -- Clear any parent scissor clipping before drawing scrollbars 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 @@ -2818,6 +2401,10 @@ function Element:update(dt) -- Always update local state for backward compatibility self._themeState = newThemeState + -- Sync theme state with Renderer module + if self._renderer then + self._renderer:setThemeState(newThemeState) + end end -- Only process button events if onEvent handler exists, element is not disabled, diff --git a/modules/Renderer.lua b/modules/Renderer.lua new file mode 100644 index 0000000..7b844de --- /dev/null +++ b/modules/Renderer.lua @@ -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