From 1293ca81a410d6e12eee664794449e8590b74394 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Wed, 12 Nov 2025 17:53:22 -0500 Subject: [PATCH] restart --- modules/Element.lua | 3487 ++++++++++++++++++++++++++++++++----- modules/EventHandler.lua | 863 --------- modules/LayoutEngine.lua | 634 ------- modules/Renderer.lua | 478 ----- modules/ScrollManager.lua | 777 --------- modules/TextEditor.lua | 1804 +++++++++---------- modules/ThemeManager.lua | 436 ----- 7 files changed, 3868 insertions(+), 4611 deletions(-) delete mode 100644 modules/EventHandler.lua delete mode 100644 modules/LayoutEngine.lua delete mode 100644 modules/Renderer.lua delete mode 100644 modules/ScrollManager.lua delete mode 100644 modules/ThemeManager.lua diff --git a/modules/Element.lua b/modules/Element.lua index c97fca3..012c67b 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -24,13 +24,6 @@ local Grid = req("Grid") local InputEvent = req("InputEvent") local StateManager = req("StateManager") --- Manager modules for composition architecture -local TextEditor = req("TextEditor") -local LayoutEngine = req("LayoutEngine") -local Renderer = req("Renderer") -local EventHandler = req("EventHandler") -local ScrollManager = req("ScrollManager") -local ThemeManager = req("ThemeManager") -- Extract utilities local enums = utils.enums local FONT_CACHE = utils.FONT_CACHE @@ -48,8 +41,10 @@ local AlignSelf = enums.AlignSelf local JustifySelf = enums.JustifySelf local FlexWrap = enums.FlexWrap +-- Reference to Gui (via GuiState) local Gui = GuiState +-- UTF-8 support (available in LÖVE/Lua 5.3+) local utf8 = utf8 or require("utf8") --[[ @@ -187,203 +182,12 @@ Public API methods to access internal state: ---@field hideScrollbars boolean|{vertical:boolean, horizontal:boolean}? -- Hide scrollbars (boolean for both, or table for individual control) ---@field userdata table? local Element = {} - --- Custom __index to proxy TextEditor properties for backward compatibility -local Element_mt = { - __index = function(t, k) - -- First check if it's an Element method/property - local v = Element[k] - if v ~= nil then - return v - end - - -- Proxy TextEditor internal fields for backward compatibility - local textEditor = rawget(t, "_textEditor") - if textEditor then - if k == "_textBuffer" then - return textEditor._textBuffer - elseif k == "_cursorPosition" then - return textEditor._cursorPosition - elseif k == "_selectionStart" then - return textEditor._selectionStart - elseif k == "_selectionEnd" then - return textEditor._selectionEnd - elseif k == "_cursorLine" then - return textEditor._cursorLine - elseif k == "_cursorColumn" then - return textEditor._cursorColumn - elseif k == "_textScrollX" then - return textEditor._textScrollX - elseif k == "_focused" then - return textEditor._focused - end - end - - return nil - end, - - __newindex = function(t, k, v) - -- Proxy TextEditor internal fields for backward compatibility - local textEditor = rawget(t, "_textEditor") - if textEditor then - if k == "_textBuffer" then - textEditor._textBuffer = v - return - elseif k == "_cursorPosition" then - textEditor._cursorPosition = v - return - elseif k == "_selectionStart" then - textEditor._selectionStart = v - return - elseif k == "_selectionEnd" then - textEditor._selectionEnd = v - return - elseif k == "_cursorLine" then - textEditor._cursorLine = v - return - elseif k == "_cursorColumn" then - textEditor._cursorColumn = v - return - elseif k == "_textScrollX" then - textEditor._textScrollX = v - return - elseif k == "_focused" then - textEditor._focused = v - return - end - end - - -- Default behavior: set the field directly - rawset(t, k, v) - end, -} - Element.__index = Element --- ============================================ --- Element Methods (Must be defined before Element.new) --- ============================================ - ---- Get border-box width (including padding) ----@return number -function Element:getBorderBoxWidth() - return self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) -end ---- Get border-box height (including padding) ----@return number -function Element:getBorderBoxHeight() - return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) -end ---- Get element bounds ----@return { x:number, y:number, width:number, height:number } -function Element:getBounds() - return { x = self.x, y = self.y, width = self:getBorderBoxWidth(), height = self:getBorderBoxHeight() } -end ---- Check if point is inside element bounds ----@param x number ----@param y number ----@return boolean -function Element:contains(x, y) - local bounds = self:getBounds() - return bounds.x <= x and bounds.y <= y and bounds.x + bounds.width >= x and bounds.y + bounds.height >= y -end ---- Calculate text width ----@return number -function Element:calculateTextWidth() - if self.text == nil then - return 0 - end - 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 - elseif self.themeComponent then - 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 - local tempFont = FONT_CACHE.get(self.textSize, fontPath) - local width = tempFont:getWidth(self.text) - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then - width = width * self.contentAutoSizingMultiplier.width - end - return width - end - local font = love.graphics.getFont() - local width = font:getWidth(self.text) - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then - width = width * self.contentAutoSizingMultiplier.width - end - return width -end ----@return number -function Element:calculateTextHeight() - if self.text == nil then - return 0 - end - local font - 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 - elseif self.themeComponent then - 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 - font = FONT_CACHE.get(self.textSize, fontPath) - else - font = love.graphics.getFont() - end - local height = font:getHeight() - if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then - local availableWidth = self.width - if (not availableWidth or availableWidth <= 0) and self.parent then - availableWidth = self.parent.width - end - if availableWidth and availableWidth > 0 then - local wrappedWidth, wrappedLines = font:getWrap(self.text, availableWidth) - height = height * #wrappedLines - end - end - if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.height then - height = height * self.contentAutoSizingMultiplier.height - end - return height -end --- Delegate auto-size calculations to LayoutEngine -function Element:calculateAutoWidth() - if self._layoutEngine then - return self._layoutEngine:calculateAutoWidth() - end - return self:calculateTextWidth() -end -function Element:calculateAutoHeight() - if self._layoutEngine then - return self._layoutEngine:calculateAutoHeight() - end - return self:calculateTextHeight() -end - --- ============================================ --- Element Constructor --- ============================================ - +---@param props ElementProps ---@return Element function Element.new(props) - local self = setmetatable({}, Element_mt) + local self = setmetatable({}, Element) self.children = {} self.onEvent = props.onEvent @@ -521,10 +325,61 @@ function Element.new(props) self.cursorBlinkRate = props.cursorBlinkRate or 0.5 -- Initialize cursor and selection state (only if editable) - -- NOTE: This is now handled by TextEditor module if self.editable then - -- These fields are now managed by TextEditor and proxied through __index/__newindex - -- Keeping minimal state for backward compatibility with non-TextEditor code paths + self._cursorPosition = 0 -- Character index (0 = before first char) + self._cursorLine = 1 -- Current line number (1-based) + self._cursorColumn = 0 -- Column within current line + self._cursorBlinkTimer = 0 + self._cursorVisible = true + self._cursorBlinkPaused = false + self._cursorBlinkPauseTimer = 0 + + -- Selection state + self._selectionStart = nil -- nil = no selection + self._selectionEnd = nil + self._selectionAnchor = nil -- Anchor point for shift+arrow selection + + -- Focus state + self._focused = false + + -- Text buffer state (initialized after self.text is set below) + self._textBuffer = props.text or "" -- Actual text content + self._lines = nil -- Split lines (for multiline) + self._wrappedLines = nil -- Wrapped line data + self._textDirty = true -- Flag to recalculate lines/wrapping + + -- Scroll state for text overflow + self._textScrollX = 0 -- Horizontal scroll offset in pixels + + -- Restore state from StateManager in immediate mode + if Gui._immediateMode and self._stateId then + local state = StateManager.getState(self._stateId) + if state then + -- Restore focus state + if state._focused then + self._focused = true + Gui._focusedElement = self + end + + -- Restore text buffer (prefer state over props for immediate mode) + if state._textBuffer and state._textBuffer ~= "" then + self._textBuffer = state._textBuffer + end + + -- Restore cursor position + if state._cursorPosition then + self._cursorPosition = state._cursorPosition + end + + -- Restore selection + if state._selectionStart then + self._selectionStart = state._selectionStart + end + if state._selectionEnd then + self._selectionEnd = state._selectionEnd + end + end + end end -- Set parent first so it's available for size calculations @@ -583,10 +438,9 @@ function Element.new(props) end -- Sync self.text with restored _textBuffer for editable elements in immediate mode - -- NOTE: This is now handled by TextEditor module after initialization - -- if self.editable and Gui._immediateMode and self._textBuffer then - -- self.text = self._textBuffer - -- end + if self.editable and Gui._immediateMode and self._textBuffer then + self.text = self._textBuffer + end self.textAlign = props.textAlign or TextAlign.START @@ -1390,93 +1244,6 @@ function Element.new(props) self._scrollbarDragOffset = 0 -- Offset from thumb top when drag started self._scrollbarPressHandled = false -- Track if scrollbar press was handled this frame - -- ============================================ - -- Initialize Manager Modules - -- ============================================ - - -- Initialize ThemeManager if using theme component - if self.themeComponent then - self._themeManager = ThemeManager.new({ - theme = self.theme, - themeComponent = self.themeComponent, - scaleCorners = self.scaleCorners, - scalingAlgorithm = self.scalingAlgorithm, - }) - self._themeManager:initialize(self) - end - - -- Initialize LayoutEngine - self._layoutEngine = LayoutEngine.new({ - positioning = self.positioning, - flexDirection = self.flexDirection, - justifyContent = self.justifyContent, - alignItems = self.alignItems, - alignContent = self.alignContent, - flexWrap = self.flexWrap, - gridRows = self.gridRows, - gridColumns = self.gridColumns, - columnGap = self.columnGap, - rowGap = self.rowGap, - }) - self._layoutEngine:initialize(self) - - -- Initialize Renderer - self._renderer = Renderer.new({ - backgroundColor = self.backgroundColor, - borderColor = self.borderColor, - cornerRadius = self.cornerRadius, - imagePath = self.imagePath, - image = self.image, - objectFit = self.objectFit, - objectPosition = self.objectPosition, - imageOpacity = self.imageOpacity, - }) - self._renderer:initialize(self) - - -- Initialize ScrollManager if needed - if self.overflow ~= "visible" or self.overflowX or self.overflowY then - self._scrollManager = ScrollManager.new({ - overflow = self.overflow, - overflowX = self.overflowX, - overflowY = self.overflowY, - scrollbarWidth = self.scrollbarWidth, - scrollbarColor = self.scrollbarColor, - scrollbarTrackColor = self.scrollbarTrackColor, - scrollbarRadius = self.scrollbarRadius, - scrollbarPadding = self.scrollbarPadding, - scrollSpeed = self.scrollSpeed, - hideScrollbars = self.hideScrollbars, - }) - self._scrollManager:initialize(self) - end - - -- Initialize TextEditor if editable - if self.editable then - self._textEditor = TextEditor.new({ - text = props.text or "", - editable = self.editable, - multiline = self.multiline, - passwordMode = self.passwordMode, - textWrap = self.textWrap, - maxLines = self.maxLines, - maxLength = self.maxLength, - placeholder = self.placeholder, - inputType = self.inputType, - textOverflow = self.textOverflow, - scrollable = self.scrollable, - autoGrow = self.autoGrow, - selectOnFocus = self.selectOnFocus, - cursorColor = self.cursorColor, - selectionColor = self.selectionColor, - cursorBlinkRate = self.cursorBlinkRate, - }) - self._textEditor:initialize(self) - end - - -- Initialize EventHandler - self._eventHandler = EventHandler.new({}) - self._eventHandler:initialize(self) - -- Register element in z-index tracking for immediate mode if Gui._immediateMode then GuiState.registerElement(self) @@ -1485,10 +1252,34 @@ function Element.new(props) return self end --- ============================================ --- Delegation Methods (Auto-generated) --- ============================================ +--- Get element bounds (content box) +---@return { x:number, y:number, width:number, height:number } +function Element:getBounds() + return { x = self.x, y = self.y, width = self:getBorderBoxWidth(), height = self:getBorderBoxHeight() } +end +--- Check if point is inside element bounds +--- @param x number +--- @param y number +--- @return boolean +function Element:contains(x, y) + local bounds = self:getBounds() + return bounds.x <= x and bounds.y <= y and bounds.x + bounds.width >= x and bounds.y + bounds.height >= y +end + +--- Get border-box width (including padding) +---@return number +function Element:getBorderBoxWidth() + return self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) +end + +--- Get border-box height (including padding) +---@return number +function Element:getBorderBoxHeight() + return self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) +end + +--- Detect if content overflows container bounds function Element:_detectOverflow() -- Reset overflow state self._overflowX = false @@ -1550,134 +1341,502 @@ function Element:_detectOverflow() self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY)) end +--- Set scroll position with bounds clamping +---@param x number? -- X scroll position (nil to keep current) +---@param y number? -- Y scroll position (nil to keep current) function Element:setScrollPosition(x, y) - if self._scrollManager then - self._scrollManager:setScroll(x, y) + if x ~= nil then + self._scrollX = math.max(0, math.min(x, self._maxScrollX)) end + if y ~= nil then + self._scrollY = math.max(0, math.min(y, self._maxScrollY)) + end + + -- Note: Scroll position is saved to ImmediateModeState in Gui.endFrame() + -- No need to save here end +--- Calculate scrollbar dimensions and positions +---@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}} function Element:_calculateScrollbarDimensions() - if self._scrollManager then - return self._scrollManager:calculateScrollbarDimensions() - end - return { + local result = { vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, } + + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + -- Vertical scrollbar + -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows + if overflowY == "scroll" then + -- Always show scrollbar for "scroll" mode + result.vertical.visible = true + result.vertical.trackHeight = self.height - (self.scrollbarPadding * 2) + + if self._overflowY then + -- Content overflows, calculate proper thumb size + local contentRatio = self.height / math.max(self._contentHeight, self.height) + result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) + + -- Calculate thumb position based on scroll ratio + local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 + local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight + result.vertical.thumbY = maxThumbY * scrollRatio + else + -- No overflow, thumb fills entire track + result.vertical.thumbHeight = result.vertical.trackHeight + result.vertical.thumbY = 0 + end + elseif self._overflowY and overflowY == "auto" then + -- Only show scrollbar when content actually overflows + result.vertical.visible = true + result.vertical.trackHeight = self.height - (self.scrollbarPadding * 2) + + -- Calculate thumb height based on content ratio + local contentRatio = self.height / math.max(self._contentHeight, self.height) + result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) + + -- Calculate thumb position based on scroll ratio + local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 + local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight + result.vertical.thumbY = maxThumbY * scrollRatio + end + + -- Horizontal scrollbar + -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows + if overflowX == "scroll" then + -- Always show scrollbar for "scroll" mode + result.horizontal.visible = true + result.horizontal.trackWidth = self.width - (self.scrollbarPadding * 2) + + if self._overflowX then + -- Content overflows, calculate proper thumb size + local contentRatio = self.width / math.max(self._contentWidth, self.width) + result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) + + -- Calculate thumb position based on scroll ratio + local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 + local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth + result.horizontal.thumbX = maxThumbX * scrollRatio + else + -- No overflow, thumb fills entire track + result.horizontal.thumbWidth = result.horizontal.trackWidth + result.horizontal.thumbX = 0 + end + elseif self._overflowX and overflowX == "auto" then + -- Only show scrollbar when content actually overflows + result.horizontal.visible = true + result.horizontal.trackWidth = self.width - (self.scrollbarPadding * 2) + + -- Calculate thumb width based on content ratio + local contentRatio = self.width / math.max(self._contentWidth, self.width) + result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) + + -- Calculate thumb position based on scroll ratio + local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 + local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth + result.horizontal.thumbX = maxThumbX * scrollRatio + end + + return result end +--- Draw scrollbars +---@param dims table -- Scrollbar dimensions from _calculateScrollbarDimensions() function Element:_drawScrollbars(dims) - if self._scrollManager then - return self._scrollManager:_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 +---@param mouseY number +---@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"} function Element:_getScrollbarAtPosition(mouseX, mouseY) - if self._scrollManager then - return self._scrollManager:_getScrollbarAtPosition(mouseX, mouseY) + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then + return nil end + + local dims = self:_calculateScrollbarDimensions() + local x, y = self.x, self.y + local w, h = self.width, self.height + + -- Check vertical scrollbar (only if not hidden) + 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 + local trackW = self.scrollbarWidth + local trackH = dims.vertical.trackHeight + + if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then + -- Check if over thumb + local thumbY = trackY + dims.vertical.thumbY + local thumbH = dims.vertical.thumbHeight + if mouseY >= thumbY and mouseY <= thumbY + thumbH then + return { component = "vertical", region = "thumb" } + else + return { component = "vertical", region = "track" } + end + end + end + + -- Check horizontal scrollbar (only if not hidden) + 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 + local trackW = dims.horizontal.trackWidth + local trackH = self.scrollbarWidth + + if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then + -- Check if over thumb + local thumbX = trackX + dims.horizontal.thumbX + local thumbW = dims.horizontal.thumbWidth + if mouseX >= thumbX and mouseX <= thumbX + thumbW then + return { component = "horizontal", region = "thumb" } + else + return { component = "horizontal", region = "track" } + end + end + end + + return nil end +--- Handle scrollbar mouse press +---@param mouseX number +---@param mouseY number +---@param button number +---@return boolean -- True if event was consumed function Element:_handleScrollbarPress(mouseX, mouseY, button) - if self._scrollManager then - return self._scrollManager:handleMousePress(mouseX, mouseY, button) + if button ~= 1 then + return false + end -- Only left click + + local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY) + if not scrollbar then + return false end + + if scrollbar.region == "thumb" then + -- Start dragging thumb + self._scrollbarDragging = true + self._hoveredScrollbar = scrollbar.component + local dims = self:_calculateScrollbarDimensions() + + if scrollbar.component == "vertical" then + local contentY = self.y + self.padding.top + local trackY = contentY + self.scrollbarPadding + local thumbY = trackY + dims.vertical.thumbY + self._scrollbarDragOffset = mouseY - thumbY + elseif scrollbar.component == "horizontal" then + local contentX = self.x + self.padding.left + local trackX = contentX + self.scrollbarPadding + local thumbX = trackX + dims.horizontal.thumbX + self._scrollbarDragOffset = mouseX - thumbX + end + + -- Update StateManager if in immediate mode + if self._stateId and Gui._immediateMode then + StateManager.updateState(self._stateId, { + scrollbarDragging = self._scrollbarDragging, + hoveredScrollbar = self._hoveredScrollbar, + scrollbarDragOffset = self._scrollbarDragOffset, + }) + end + + return true -- Event consumed + elseif scrollbar.region == "track" then + -- Click on track - jump to position + self:_scrollToTrackPosition(mouseX, mouseY, scrollbar.component) + return true + end + return false end +--- Handle scrollbar drag +---@param mouseX number +---@param mouseY number +---@return boolean -- True if event was consumed function Element:_handleScrollbarDrag(mouseX, mouseY) - if self._scrollManager then - return self._scrollManager:handleMouseMove(mouseX, mouseY) + if not self._scrollbarDragging then + return false end + + local dims = self:_calculateScrollbarDimensions() + + if self._hoveredScrollbar == "vertical" then + local contentY = self.y + self.padding.top + local trackY = contentY + self.scrollbarPadding + local trackH = dims.vertical.trackHeight + local thumbH = dims.vertical.thumbHeight + + -- Calculate new thumb position + local newThumbY = mouseY - self._scrollbarDragOffset - trackY + newThumbY = math.max(0, math.min(newThumbY, trackH - thumbH)) + + -- Convert thumb position to scroll position + local scrollRatio = (trackH - thumbH) > 0 and (newThumbY / (trackH - thumbH)) or 0 + local newScrollY = scrollRatio * self._maxScrollY + + self:setScrollPosition(nil, newScrollY) + return true + elseif self._hoveredScrollbar == "horizontal" then + local contentX = self.x + self.padding.left + local trackX = contentX + self.scrollbarPadding + local trackW = dims.horizontal.trackWidth + local thumbW = dims.horizontal.thumbWidth + + -- Calculate new thumb position + local newThumbX = mouseX - self._scrollbarDragOffset - trackX + newThumbX = math.max(0, math.min(newThumbX, trackW - thumbW)) + + -- Convert thumb position to scroll position + local scrollRatio = (trackW - thumbW) > 0 and (newThumbX / (trackW - thumbW)) or 0 + local newScrollX = scrollRatio * self._maxScrollX + + self:setScrollPosition(newScrollX, nil) + return true + end + return false end +--- Handle scrollbar release +---@param button number +---@return boolean -- True if event was consumed function Element:_handleScrollbarRelease(button) - if self._scrollManager then - return self._scrollManager:handleMouseRelease(nil, nil, button) + if button ~= 1 then + return false end + + if self._scrollbarDragging then + self._scrollbarDragging = false + + -- Update StateManager if in immediate mode + if self._stateId and Gui._immediateMode then + StateManager.updateState(self._stateId, { + scrollbarDragging = false, + }) + end + + return true + end + return false end +--- Scroll to track click position +---@param mouseX number +---@param mouseY number +---@param component string -- "vertical" or "horizontal" function Element:_scrollToTrackPosition(mouseX, mouseY, component) - if self._scrollManager then - return self._scrollManager:_scrollToTrackPosition(mouseX, mouseY, component) + local dims = self:_calculateScrollbarDimensions() + + if component == "vertical" then + local contentY = self.y + self.padding.top + local trackY = contentY + self.scrollbarPadding + local trackH = dims.vertical.trackHeight + local thumbH = dims.vertical.thumbHeight + + -- Calculate target thumb position (centered on click) + local targetThumbY = mouseY - trackY - (thumbH / 2) + targetThumbY = math.max(0, math.min(targetThumbY, trackH - thumbH)) + + -- Convert to scroll position + local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0 + local newScrollY = scrollRatio * self._maxScrollY + + self:setScrollPosition(nil, newScrollY) + elseif component == "horizontal" then + local contentX = self.x + self.padding.left + local trackX = contentX + self.scrollbarPadding + local trackW = dims.horizontal.trackWidth + local thumbW = dims.horizontal.thumbWidth + + -- Calculate target thumb position (centered on click) + local targetThumbX = mouseX - trackX - (thumbW / 2) + targetThumbX = math.max(0, math.min(targetThumbX, trackW - thumbW)) + + -- Convert to scroll position + local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0 + local newScrollX = scrollRatio * self._maxScrollX + + self:setScrollPosition(newScrollX, nil) end end +--- Handle mouse wheel scrolling +---@param x number -- Horizontal scroll amount +---@param y number -- Vertical scroll amount +---@return boolean -- True if scroll was handled function Element:_handleWheelScroll(x, y) - if self._scrollManager then - return self._scrollManager:handleWheel(x, y) + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + + if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then + return false end - return false + + local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0 + local hasHorizontalOverflow = self._overflowX and self._maxScrollX > 0 + + local scrolled = false + + -- Vertical scrolling + if y ~= 0 and hasVerticalOverflow then + local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up + local newScrollY = self._scrollY + delta + self:setScrollPosition(nil, newScrollY) + scrolled = true + end + + -- Horizontal scrolling + if x ~= 0 and hasHorizontalOverflow then + local delta = -x * self.scrollSpeed + local newScrollX = self._scrollX + delta + self:setScrollPosition(newScrollX, nil) + scrolled = true + end + + -- Note: Scroll position is saved to ImmediateModeState in Gui.endFrame() + return scrolled end +--- Get current scroll position +---@return number scrollX, number scrollY function Element:getScrollPosition() - if self._scrollManager then - return self._scrollManager:getScroll() - end - return 0, 0 + return self._scrollX, self._scrollY end +--- Get maximum scroll bounds +---@return number maxScrollX, number maxScrollY function Element:getMaxScroll() - if self._scrollManager then - local _, _, maxScrollX, maxScrollY = self._scrollManager:getContentBounds() - return maxScrollX, maxScrollY - end - return 0, 0 + return self._maxScrollX, self._maxScrollY end +--- Get scroll percentage (0-1) +---@return number percentX, number percentY function Element:getScrollPercentage() - if self._scrollManager then - local scrollX, scrollY = self._scrollManager:getScroll() - local _, _, maxScrollX, maxScrollY = self._scrollManager:getContentBounds() - local percentX = maxScrollX > 0 and (scrollX / maxScrollX) or 0 - local percentY = maxScrollY > 0 and (scrollY / maxScrollY) or 0 - return percentX, percentY - end - return 0, 0 + local percentX = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 + local percentY = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 + return percentX, percentY end +--- Check if element has overflow +---@return boolean hasOverflowX, boolean hasOverflowY function Element:hasOverflow() return self._overflowX, self._overflowY end +--- Get content dimensions (including overflow) +---@return number contentWidth, number contentHeight function Element:getContentSize() - if self._scrollManager then - local contentWidth, contentHeight = self._scrollManager:getContentBounds() - return contentWidth, contentHeight - end - return self.width, self.height + return self._contentWidth, self._contentHeight end +--- Scroll by delta amount +---@param dx number? -- X delta (nil for no change) +---@param dy number? -- Y delta (nil for no change) function Element:scrollBy(dx, dy) - if self._scrollManager then - self._scrollManager:scroll(dx, dy) + if dx then + self._scrollX = math.max(0, math.min(self._scrollX + dx, self._maxScrollX)) + end + if dy then + self._scrollY = math.max(0, math.min(self._scrollY + dy, self._maxScrollY)) end end +--- Scroll to top function Element:scrollToTop() self:setScrollPosition(nil, 0) end +--- Scroll to bottom function Element:scrollToBottom() - if self._scrollManager then - local _, _, _, maxScrollY = self._scrollManager:getContentBounds() - self:setScrollPosition(nil, maxScrollY) - end + self:setScrollPosition(nil, self._maxScrollY) end +--- Scroll to left function Element:scrollToLeft() self:setScrollPosition(0, nil) end +--- Scroll to right function Element:scrollToRight() - if self._scrollManager then - local _, _, maxScrollX = self._scrollManager:getContentBounds() - self:setScrollPosition(maxScrollX, nil) - end + self:setScrollPosition(self._maxScrollX, nil) end +--- Get the current state's scaled content padding +--- Returns the contentPadding for the current theme state, scaled to the element's size +---@return table|nil -- {left, top, right, bottom} or nil if no contentPadding function Element:getScaledContentPadding() if not self.themeComponent then return nil @@ -1728,6 +1887,8 @@ function Element:getScaledContentPadding() end end +--- Get or create blur instance for this element +---@return table? -- Blur instance or nil if no blur configured function Element:getBlurInstance() -- Determine quality from contentBlur or backdropBlur local quality = 5 -- Default quality @@ -1745,18 +1906,56 @@ function Element:getBlurInstance() return self._blurInstance end +--- Get available content width for children (accounting for 9-patch content padding) +--- This is the width that children should use when calculating percentage widths +---@return number function Element:getAvailableContentWidth() - if self._layoutEngine then - return self._layoutEngine:getAvailableContentWidth() + local availableWidth = self.width + + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + -- Check if the element is using the scaled 9-patch contentPadding as its padding + -- Allow small floating point differences (within 0.1 pixels) + local usingContentPaddingAsPadding = ( + math.abs(self.padding.left - scaledContentPadding.left) < 0.1 and math.abs(self.padding.right - scaledContentPadding.right) < 0.1 + ) + + if not usingContentPaddingAsPadding then + -- Element has explicit padding different from contentPadding + -- Subtract scaled contentPadding to get the area children should use + availableWidth = availableWidth - scaledContentPadding.left - scaledContentPadding.right + end end + + return math.max(0, availableWidth) end +--- Get available content height for children (accounting for 9-patch content padding) +--- This is the height that children should use when calculating percentage heights +---@return number function Element:getAvailableContentHeight() - if self._layoutEngine then - return self._layoutEngine:getAvailableContentHeight() + local availableHeight = self.height + + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + -- Check if the element is using the scaled 9-patch contentPadding as its padding + -- Allow small floating point differences (within 0.1 pixels) + local usingContentPaddingAsPadding = ( + math.abs(self.padding.top - scaledContentPadding.top) < 0.1 and math.abs(self.padding.bottom - scaledContentPadding.bottom) < 0.1 + ) + + if not usingContentPaddingAsPadding then + -- Element has explicit padding different from contentPadding + -- Subtract scaled contentPadding to get the area children should use + availableHeight = availableHeight - scaledContentPadding.top - scaledContentPadding.bottom + end end + + return math.max(0, availableHeight) end +--- Add child to element +---@param child Element function Element:addChild(child) child.parent = self @@ -1827,18 +2026,447 @@ function Element:addChild(child) end end +--- Apply positioning offsets (top, right, bottom, left) to an element +-- @param element The element to apply offsets to function Element:applyPositioningOffsets(element) - if self._layoutEngine then - return self._layoutEngine:applyPositioningOffsets(element) + if not element then + return + end + + -- For CSS-style positioning, we need the parent's bounds + local parent = element.parent + if not parent then + return + end + + -- Only apply offsets to explicitly absolute children or children in relative/absolute containers + -- Flex/grid children ignore positioning offsets as they participate in layout + local isFlexChild = element.positioning == Positioning.FLEX + or element.positioning == Positioning.GRID + or (element.positioning == Positioning.ABSOLUTE and not element._explicitlyAbsolute) + + if not isFlexChild then + -- Apply absolute positioning for explicitly absolute children + -- Apply top offset (distance from parent's content box top edge) + if element.top then + element.y = parent.y + parent.padding.top + element.top + end + + -- Apply bottom offset (distance from parent's content box bottom edge) + -- BORDER-BOX MODEL: Use border-box dimensions for positioning + if element.bottom then + local elementBorderBoxHeight = element:getBorderBoxHeight() + element.y = parent.y + parent.padding.top + parent.height - element.bottom - elementBorderBoxHeight + end + + -- Apply left offset (distance from parent's content box left edge) + if element.left then + element.x = parent.x + parent.padding.left + element.left + end + + -- Apply right offset (distance from parent's content box right edge) + -- BORDER-BOX MODEL: Use border-box dimensions for positioning + if element.right then + local elementBorderBoxWidth = element:getBorderBoxWidth() + element.x = parent.x + parent.padding.left + parent.width - element.right - elementBorderBoxWidth + end end end function Element:layoutChildren() - if self._layoutEngine then - return self._layoutEngine:layoutChildren() + if self.positioning == Positioning.ABSOLUTE or self.positioning == Positioning.RELATIVE then + -- Absolute/Relative positioned containers don't layout their children according to flex rules, + -- but they should still apply CSS positioning offsets to their children + for _, child in ipairs(self.children) do + if child.top or child.right or child.bottom or child.left then + self:applyPositioningOffsets(child) + end + end + return end + + -- Handle grid layout + if self.positioning == Positioning.GRID then + Grid.layoutGridItems(self) + return + end + + local childCount = #self.children + + if childCount == 0 then + return + end + + -- Get flex children (children that participate in flex layout) + local flexChildren = {} + for _, child in ipairs(self.children) do + local isFlexChild = not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) + if isFlexChild then + table.insert(flexChildren, child) + end + end + + if #flexChildren == 0 then + return + end + + -- Calculate space reserved by absolutely positioned siblings with explicit positioning + local reservedMainStart = 0 -- Space reserved at the start of main axis (left for horizontal, top for vertical) + local reservedMainEnd = 0 -- Space reserved at the end of main axis (right for horizontal, bottom for vertical) + local reservedCrossStart = 0 -- Space reserved at the start of cross axis (top for horizontal, left for vertical) + local reservedCrossEnd = 0 -- Space reserved at the end of cross axis (bottom for horizontal, right for vertical) + + for _, child in ipairs(self.children) do + -- Only consider absolutely positioned children with explicit positioning + if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then + -- BORDER-BOX MODEL: Use border-box dimensions for space calculations + local childBorderBoxWidth = child:getBorderBoxWidth() + local childBorderBoxHeight = child:getBorderBoxHeight() + + if self.flexDirection == FlexDirection.HORIZONTAL then + -- Horizontal layout: main axis is X, cross axis is Y + -- Check for left positioning (reserves space at main axis start) + if child.left then + local spaceNeeded = child.left + childBorderBoxWidth + reservedMainStart = math.max(reservedMainStart, spaceNeeded) + end + -- Check for right positioning (reserves space at main axis end) + if child.right then + local spaceNeeded = child.right + childBorderBoxWidth + reservedMainEnd = math.max(reservedMainEnd, spaceNeeded) + end + -- Check for top positioning (reserves space at cross axis start) + if child.top then + local spaceNeeded = child.top + childBorderBoxHeight + reservedCrossStart = math.max(reservedCrossStart, spaceNeeded) + end + -- Check for bottom positioning (reserves space at cross axis end) + if child.bottom then + local spaceNeeded = child.bottom + childBorderBoxHeight + reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded) + end + else + -- Vertical layout: main axis is Y, cross axis is X + -- Check for top positioning (reserves space at main axis start) + if child.top then + local spaceNeeded = child.top + childBorderBoxHeight + reservedMainStart = math.max(reservedMainStart, spaceNeeded) + end + -- Check for bottom positioning (reserves space at main axis end) + if child.bottom then + local spaceNeeded = child.bottom + childBorderBoxHeight + reservedMainEnd = math.max(reservedMainEnd, spaceNeeded) + end + -- Check for left positioning (reserves space at cross axis start) + if child.left then + local spaceNeeded = child.left + childBorderBoxWidth + reservedCrossStart = math.max(reservedCrossStart, spaceNeeded) + end + -- Check for right positioning (reserves space at cross axis end) + if child.right then + local spaceNeeded = child.right + childBorderBoxWidth + reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded) + end + end + end + end + + -- Calculate available space (accounting for padding and reserved space) + -- BORDER-BOX MODEL: self.width and self.height are already content dimensions (padding subtracted) + local availableMainSize = 0 + local availableCrossSize = 0 + if self.flexDirection == FlexDirection.HORIZONTAL then + availableMainSize = self.width - reservedMainStart - reservedMainEnd + availableCrossSize = self.height - reservedCrossStart - reservedCrossEnd + else + availableMainSize = self.height - reservedMainStart - reservedMainEnd + availableCrossSize = self.width - reservedCrossStart - reservedCrossEnd + end + + -- Handle flex wrap: create lines of children + local lines = {} + + if self.flexWrap == FlexWrap.NOWRAP then + -- All children go on one line + lines[1] = flexChildren + else + -- Wrap children into multiple lines + local currentLine = {} + local currentLineSize = 0 + + for _, child in ipairs(flexChildren) do + -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations + -- Include margins in size calculations + local childMainSize = 0 + local childMainMargin = 0 + if self.flexDirection == FlexDirection.HORIZONTAL then + childMainSize = child:getBorderBoxWidth() + childMainMargin = child.margin.left + child.margin.right + else + childMainSize = child:getBorderBoxHeight() + childMainMargin = child.margin.top + child.margin.bottom + end + local childTotalMainSize = childMainSize + childMainMargin + + -- Check if adding this child would exceed the available space + local lineSpacing = #currentLine > 0 and self.gap or 0 + if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableMainSize then + -- Start a new line + if #currentLine > 0 then + table.insert(lines, currentLine) + end + currentLine = { child } + currentLineSize = childTotalMainSize + else + -- Add to current line + table.insert(currentLine, child) + currentLineSize = currentLineSize + lineSpacing + childTotalMainSize + end + end + + -- Add the last line if it has children + if #currentLine > 0 then + table.insert(lines, currentLine) + end + + -- Handle wrap-reverse: reverse the order of lines + if self.flexWrap == FlexWrap.WRAP_REVERSE then + local reversedLines = {} + for i = #lines, 1, -1 do + table.insert(reversedLines, lines[i]) + end + lines = reversedLines + end + end + + -- Calculate line positions and heights (including child padding) + local lineHeights = {} + local totalLinesHeight = 0 + + for lineIndex, line in ipairs(lines) do + local maxCrossSize = 0 + for _, child in ipairs(line) do + -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations + -- Include margins in cross-axis size calculations + local childCrossSize = 0 + local childCrossMargin = 0 + if self.flexDirection == FlexDirection.HORIZONTAL then + childCrossSize = child:getBorderBoxHeight() + childCrossMargin = child.margin.top + child.margin.bottom + else + childCrossSize = child:getBorderBoxWidth() + childCrossMargin = child.margin.left + child.margin.right + end + local childTotalCrossSize = childCrossSize + childCrossMargin + maxCrossSize = math.max(maxCrossSize, childTotalCrossSize) + end + lineHeights[lineIndex] = maxCrossSize + totalLinesHeight = totalLinesHeight + maxCrossSize + end + + -- Account for gaps between lines + local lineGaps = math.max(0, #lines - 1) * self.gap + totalLinesHeight = totalLinesHeight + lineGaps + + -- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size + if #lines == 1 then + if self.alignItems == AlignItems.STRETCH or self.alignItems == AlignItems.CENTER or self.alignItems == AlignItems.FLEX_END then + -- STRETCH, CENTER, and FLEX_END should use full available cross size + lineHeights[1] = availableCrossSize + totalLinesHeight = availableCrossSize + end + -- CENTER and FLEX_END should preserve natural child dimensions + -- and only affect positioning within the available space + end + + -- Calculate starting position for lines based on alignContent + local lineStartPos = 0 + local lineSpacing = self.gap + local freeLineSpace = availableCrossSize - totalLinesHeight + + -- Apply AlignContent logic for both single and multiple lines + if self.alignContent == AlignContent.FLEX_START then + lineStartPos = 0 + elseif self.alignContent == AlignContent.CENTER then + lineStartPos = freeLineSpace / 2 + elseif self.alignContent == AlignContent.FLEX_END then + lineStartPos = freeLineSpace + elseif self.alignContent == AlignContent.SPACE_BETWEEN then + lineStartPos = 0 + if #lines > 1 then + lineSpacing = self.gap + (freeLineSpace / (#lines - 1)) + end + elseif self.alignContent == AlignContent.SPACE_AROUND then + local spaceAroundEach = freeLineSpace / #lines + lineStartPos = spaceAroundEach / 2 + lineSpacing = self.gap + spaceAroundEach + elseif self.alignContent == AlignContent.STRETCH then + lineStartPos = 0 + if #lines > 1 and freeLineSpace > 0 then + lineSpacing = self.gap + (freeLineSpace / #lines) + -- Distribute extra space to line heights (only if positive) + local extraPerLine = freeLineSpace / #lines + for i = 1, #lineHeights do + lineHeights[i] = lineHeights[i] + extraPerLine + end + end + end + + -- Position children within each line + local currentCrossPos = lineStartPos + + for lineIndex, line in ipairs(lines) do + local lineHeight = lineHeights[lineIndex] + + -- Calculate total size of children in this line (including padding and margins) + -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations + local totalChildrenSize = 0 + for _, child in ipairs(line) do + if self.flexDirection == FlexDirection.HORIZONTAL then + totalChildrenSize = totalChildrenSize + child:getBorderBoxWidth() + child.margin.left + child.margin.right + else + totalChildrenSize = totalChildrenSize + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + end + end + + local totalGapSize = math.max(0, #line - 1) * self.gap + local totalContentSize = totalChildrenSize + totalGapSize + local freeSpace = availableMainSize - totalContentSize + + -- Calculate initial position and spacing based on justifyContent + local startPos = 0 + local itemSpacing = self.gap + + if self.justifyContent == JustifyContent.FLEX_START then + startPos = 0 + elseif self.justifyContent == JustifyContent.CENTER then + startPos = freeSpace / 2 + elseif self.justifyContent == JustifyContent.FLEX_END then + startPos = freeSpace + elseif self.justifyContent == JustifyContent.SPACE_BETWEEN then + startPos = 0 + if #line > 1 then + itemSpacing = self.gap + (freeSpace / (#line - 1)) + end + elseif self.justifyContent == JustifyContent.SPACE_AROUND then + local spaceAroundEach = freeSpace / #line + startPos = spaceAroundEach / 2 + itemSpacing = self.gap + spaceAroundEach + elseif self.justifyContent == JustifyContent.SPACE_EVENLY then + local spaceBetween = freeSpace / (#line + 1) + startPos = spaceBetween + itemSpacing = self.gap + spaceBetween + end + + -- Position children in this line + local currentMainPos = startPos + + for _, child in ipairs(line) do + -- Determine effective cross-axis alignment + local effectiveAlign = child.alignSelf + if effectiveAlign == nil or effectiveAlign == AlignSelf.AUTO then + effectiveAlign = self.alignItems + end + + if self.flexDirection == FlexDirection.HORIZONTAL then + -- Horizontal layout: main axis is X, cross axis is Y + -- Position child at border box (x, y represents top-left including padding) + -- Add reservedMainStart and left margin to account for absolutely positioned siblings and margins + child.x = self.x + self.padding.left + reservedMainStart + currentMainPos + child.margin.left + + -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations + local childBorderBoxHeight = child:getBorderBoxHeight() + local childTotalCrossSize = childBorderBoxHeight + child.margin.top + child.margin.bottom + + if effectiveAlign == AlignItems.FLEX_START then + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + child.margin.top + elseif effectiveAlign == AlignItems.CENTER then + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.top + elseif effectiveAlign == AlignItems.FLEX_END then + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.top + elseif effectiveAlign == AlignItems.STRETCH then + -- STRETCH: Only apply if height was not explicitly set + if child.autosizing and child.autosizing.height then + -- STRETCH: Set border-box height to lineHeight minus margins, content area shrinks to fit + local availableHeight = lineHeight - child.margin.top - child.margin.bottom + child._borderBoxHeight = availableHeight + child.height = math.max(0, availableHeight - child.padding.top - child.padding.bottom) + end + child.y = self.y + self.padding.top + reservedCrossStart + currentCrossPos + child.margin.top + end + + -- Apply positioning offsets (top, right, bottom, left) + self:applyPositioningOffsets(child) + + -- If child has children, re-layout them after position change + if #child.children > 0 then + child:layoutChildren() + end + + -- Advance position by child's border-box width plus margins + currentMainPos = currentMainPos + child:getBorderBoxWidth() + child.margin.left + child.margin.right + itemSpacing + else + -- Vertical layout: main axis is Y, cross axis is X + -- Position child at border box (x, y represents top-left including padding) + -- Add reservedMainStart and top margin to account for absolutely positioned siblings and margins + child.y = self.y + self.padding.top + reservedMainStart + currentMainPos + child.margin.top + + -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations + local childBorderBoxWidth = child:getBorderBoxWidth() + local childTotalCrossSize = childBorderBoxWidth + child.margin.left + child.margin.right + + if effectiveAlign == AlignItems.FLEX_START then + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + child.margin.left + elseif effectiveAlign == AlignItems.CENTER then + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.left + elseif effectiveAlign == AlignItems.FLEX_END then + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.left + elseif effectiveAlign == AlignItems.STRETCH then + -- STRETCH: Only apply if width was not explicitly set + if child.autosizing and child.autosizing.width then + -- STRETCH: Set border-box width to lineHeight minus margins, content area shrinks to fit + local availableWidth = lineHeight - child.margin.left - child.margin.right + child._borderBoxWidth = availableWidth + child.width = math.max(0, availableWidth - child.padding.left - child.padding.right) + end + child.x = self.x + self.padding.left + reservedCrossStart + currentCrossPos + child.margin.left + end + + -- Apply positioning offsets (top, right, bottom, left) + self:applyPositioningOffsets(child) + + -- If child has children, re-layout them after position change + if #child.children > 0 then + child:layoutChildren() + end + + -- Advance position by child's border-box height plus margins + currentMainPos = currentMainPos + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + itemSpacing + end + end + + -- Move to next line position + currentCrossPos = currentCrossPos + lineHeight + lineSpacing + end + + -- Position explicitly absolute children after flex layout + for _, child in ipairs(self.children) do + if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then + -- Apply positioning offsets (top, right, bottom, left) + self:applyPositioningOffsets(child) + + -- If child has children, layout them after position change + if #child.children > 0 then + child:layoutChildren() + end + end + end + + -- Detect overflow after children are laid out + self:_detectOverflow() end +--- Destroy element and its children function Element:destroy() -- Remove from global elements list for i, win in ipairs(Gui.topElements) do @@ -1873,20 +2501,985 @@ function Element:destroy() -- Clear animation reference self.animation = nil + + -- Clear onEvent to prevent closure leaks + self.onEvent = nil end +--- Draw element and its children function Element:draw(backdropCanvas) - if self._renderer then - return self._renderer:draw(backdropCanvas) + -- Early exit if element is invisible (optimization) + if self.opacity <= 0 then + return + end + + -- Handle opacity during animation + local drawBackgroundColor = self.backgroundColor + if self.animation then + local anim = self.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 = 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 + + -- 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.editable then + self:_updateTextIfDirty() + self:_updateAutoGrowHeight() + end + + -- For editable elements, use _textBuffer; for non-editable, use text + local displayText = self.editable and self._textBuffer 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.editable and self._focused and self._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 new method that handles multiline + local cursorRelX, cursorRelY = self:_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._textScrollX then + cursorX = cursorX - self._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.editable and self._focused and self:hasSelection() and self.text and self.text ~= "" then + local selStart, selEnd = self: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 (handles multiline and wrapping) + local selectionRects = self:_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._textScrollX then + rectX = rectX - self._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.editable and self._focused and self._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) + if self.onEvent and not self.disableHighlight then + -- Check if any button is pressed + local anyPressed = false + for _, pressed in pairs(self._pressed) do + if pressed then + anyPressed = true + break + end + end + if anyPressed then + -- 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) + end + end + + -- Sort children by z-index before drawing + local sortedChildren = {} + for _, child in ipairs(self.children) do + table.insert(sortedChildren, child) + end + table.sort(sortedChildren, function(a, b) + return a.z < b.z + end) + + -- Check if we need to clip children to rounded corners + local hasRoundedCorners = self.cornerRadius.topLeft > 0 + or self.cornerRadius.topRight > 0 + or self.cornerRadius.bottomLeft > 0 + or self.cornerRadius.bottomRight > 0 + + -- Helper function to draw children (with or without clipping) + local function drawChildren() + -- Determine overflow behavior per axis (matches HTML/CSS behavior) + -- Priority: axis-specific (overflowX/Y) > general (overflow) > default (hidden) + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + local needsOverflowClipping = (overflowX ~= "visible" or overflowY ~= "visible") and (overflowX ~= nil or overflowY ~= nil) + + -- Apply scroll offset if overflow is not visible + local hasScrollOffset = needsOverflowClipping and (self._scrollX ~= 0 or self._scrollY ~= 0) + + if hasRoundedCorners and #sortedChildren > 0 then + -- Use stencil to clip children to rounded rectangle + -- BORDER-BOX MODEL: Use stored border-box dimensions for clipping + 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 stencilFunc = RoundedRect.stencilFunction(self.x, self.y, borderBoxWidth, borderBoxHeight, self.cornerRadius) + + -- Temporarily disable canvas for stencil operation (LÖVE 11.5 workaround) + local currentCanvas = love.graphics.getCanvas() + love.graphics.setCanvas() + love.graphics.stencil(stencilFunc, "replace", 1) + love.graphics.setCanvas(currentCanvas) + + love.graphics.setStencilTest("greater", 0) + + -- Apply scroll offset AFTER clipping is set + if hasScrollOffset then + love.graphics.push() + love.graphics.translate(-self._scrollX, -self._scrollY) + end + + for _, child in ipairs(sortedChildren) do + child:draw(backdropCanvas) + end + + if hasScrollOffset then + love.graphics.pop() + end + + love.graphics.setStencilTest() + elseif needsOverflowClipping and #sortedChildren > 0 then + -- Clip content for overflow hidden/scroll/auto without rounded corners + local contentX = self.x + self.padding.left + local contentY = self.y + self.padding.top + local contentWidth = self.width + local contentHeight = self.height + + love.graphics.setScissor(contentX, contentY, contentWidth, contentHeight) + + -- Apply scroll offset AFTER clipping is set + if hasScrollOffset then + love.graphics.push() + love.graphics.translate(-self._scrollX, -self._scrollY) + end + + for _, child in ipairs(sortedChildren) do + child:draw(backdropCanvas) + end + + if hasScrollOffset then + love.graphics.pop() + end + + love.graphics.setScissor() + else + -- No clipping needed + for _, child in ipairs(sortedChildren) do + child:draw(backdropCanvas) + end + end + end + + -- Apply content blur if configured + if self.contentBlur and self.contentBlur.intensity > 0 and #sortedChildren > 0 then + local blurInstance = self:getBlurInstance() + if blurInstance then + Blur.applyToRegion(blurInstance, self.contentBlur.intensity, self.x, self.y, borderBoxWidth, borderBoxHeight, drawChildren) + else + drawChildren() + end + else + drawChildren() + end + + -- Draw scrollbars if overflow is scroll or auto + -- IMPORTANT: Scrollbars must be drawn without parent clipping + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + if overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto" then + local scrollbarDims = self:_calculateScrollbarDimensions() + if scrollbarDims.vertical.visible or scrollbarDims.horizontal.visible then + -- Clear any parent scissor clipping before drawing scrollbars + love.graphics.setScissor() + self:_drawScrollbars(scrollbarDims) + end end end +--- Update element (propagate to children) +---@param dt number function Element:update(dt) - if self._eventHandler then - return self._eventHandler:update(dt) + -- Restore scrollbar state from StateManager in immediate mode + if self._stateId and Gui._immediateMode then + local state = StateManager.getState(self._stateId) + if state then + self._scrollbarHoveredVertical = state.scrollbarHoveredVertical or false + self._scrollbarHoveredHorizontal = state.scrollbarHoveredHorizontal or false + self._scrollbarDragging = state.scrollbarDragging or false + self._hoveredScrollbar = state.hoveredScrollbar + self._scrollbarDragOffset = state.scrollbarDragOffset or 0 + end + end + + for _, child in ipairs(self.children) do + child:update(dt) + end + + -- Update cursor blink timer (only if editable and focused) + if self.editable and self._focused then + -- If blink is paused, increment pause timer + if self._cursorBlinkPaused then + self._cursorBlinkPauseTimer = (self._cursorBlinkPauseTimer or 0) + dt + -- Unpause after 0.5 seconds of no typing + if self._cursorBlinkPauseTimer >= 0.5 then + self._cursorBlinkPaused = false + self._cursorBlinkPauseTimer = 0 + end + else + -- Normal blinking + self._cursorBlinkTimer = self._cursorBlinkTimer + dt + if self._cursorBlinkTimer >= self.cursorBlinkRate then + self._cursorBlinkTimer = 0 + self._cursorVisible = not self._cursorVisible + end + end + end + + -- Update animation if exists + if self.animation then + local finished = self.animation:update(dt) + if finished then + self.animation = nil -- remove finished animation + else + -- Apply animation interpolation during update + local anim = self.animation:interpolate() + self.width = anim.width or self.width + self.height = anim.height or self.height + self.opacity = anim.opacity or self.opacity + -- Update background color with interpolated opacity + if anim.opacity then + self.backgroundColor.a = anim.opacity + end + end + end + + local mx, my = love.mouse.getPosition() + + local scrollbar = self:_getScrollbarAtPosition(mx, my) + + -- Update independent hover states for vertical and horizontal scrollbars + if scrollbar and scrollbar.component == "vertical" then + self._scrollbarHoveredVertical = true + self._hoveredScrollbar = "vertical" + else + if not (self._scrollbarDragging and self._hoveredScrollbar == "vertical") then + self._scrollbarHoveredVertical = false + end + end + + if scrollbar and scrollbar.component == "horizontal" then + self._scrollbarHoveredHorizontal = true + self._hoveredScrollbar = "horizontal" + else + if not (self._scrollbarDragging and self._hoveredScrollbar == "horizontal") then + self._scrollbarHoveredHorizontal = false + end + end + + -- Clear hoveredScrollbar if neither is hovered + if not scrollbar and not self._scrollbarDragging then + self._hoveredScrollbar = nil + end + + -- Update scrollbar state in StateManager if in immediate mode + if self._stateId and Gui._immediateMode then + StateManager.updateState(self._stateId, { + scrollbarHoveredVertical = self._scrollbarHoveredVertical, + scrollbarHoveredHorizontal = self._scrollbarHoveredHorizontal, + scrollbarDragging = self._scrollbarDragging, + hoveredScrollbar = self._hoveredScrollbar, + }) + end + + -- Handle scrollbar dragging + if self._scrollbarDragging and love.mouse.isDown(1) then + self:_handleScrollbarDrag(mx, my) + elseif self._scrollbarDragging then + -- Mouse button released + self._scrollbarDragging = false + + -- Update StateManager if in immediate mode + if self._stateId and Gui._immediateMode then + StateManager.updateState(self._stateId, { + scrollbarDragging = false, + }) + end + end + + -- Handle scrollbar click/press (independent of onEvent) + -- Check if we should handle scrollbar press for elements with overflow + local overflowX = self.overflowX or self.overflow + local overflowY = self.overflowY or self.overflow + local hasScrollableOverflow = (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") + + if hasScrollableOverflow and not self._scrollbarDragging then + -- Check for scrollbar press on left mouse button + if love.mouse.isDown(1) and not self._scrollbarPressHandled then + local scrollbarPressed = self:_handleScrollbarPress(mx, my, 1) + if scrollbarPressed then + self._scrollbarPressHandled = true + end + elseif not love.mouse.isDown(1) then + -- Reset press handled flag when button is released + self._scrollbarPressHandled = false + end + end + + if self.onEvent or self.themeComponent or self.editable then + -- Clickable area is the border box (x, y already includes padding) + -- BORDER-BOX MODEL: Use stored border-box dimensions for hit detection + local bx = self.x + local by = self.y + local bw = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + local bh = self._borderBoxHeight or (self.height + self.padding.top + self.padding.bottom) + + -- Account for scroll offsets from parent containers + -- Walk up the parent chain and accumulate scroll offsets + local scrollOffsetX = 0 + local scrollOffsetY = 0 + local current = self.parent + while current do + local overflowX = current.overflowX or current.overflow + local overflowY = current.overflowY or current.overflow + local hasScrollableOverflow = ( + overflowX == "scroll" + or overflowX == "auto" + or overflowY == "scroll" + or overflowY == "auto" + or overflowX == "hidden" + or overflowY == "hidden" + ) + if hasScrollableOverflow then + scrollOffsetX = scrollOffsetX + (current._scrollX or 0) + scrollOffsetY = scrollOffsetY + (current._scrollY or 0) + end + current = current.parent + end + + -- Adjust mouse position by accumulated scroll offset for hit testing + local adjustedMx = mx + scrollOffsetX + local adjustedMy = my + scrollOffsetY + local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh + + -- Check if this is the topmost element at the mouse position (z-index ordering) + -- This prevents blocked elements from receiving interactions or visual feedback + local isActiveElement + if Gui._immediateMode then + -- In immediate mode, use z-index occlusion detection + local topElement = GuiState.getTopElementAt(mx, my) + isActiveElement = (topElement == self or topElement == nil) + else + -- In retained mode, use the old _activeEventElement mechanism + isActiveElement = (Gui._activeEventElement == nil or Gui._activeEventElement == self) + end + + -- Update theme state based on interaction + if self.themeComponent then + local newThemeState = "normal" + + -- Disabled state takes priority + if self.disabled then + newThemeState = "disabled" + -- Active state (for inputs when focused/typing) + elseif self.active then + newThemeState = "active" + -- Only show hover/pressed states if this element is active (not blocked) + elseif isHovering and isActiveElement then + -- Check if any button is pressed + local anyPressed = false + for _, pressed in pairs(self._pressed) do + if pressed then + anyPressed = true + break + end + end + + if anyPressed then + newThemeState = "pressed" + else + newThemeState = "hover" + end + end + + -- Update state (in StateManager if in immediate mode, otherwise locally) + if self._stateId and Gui._immediateMode then + -- Update in StateManager for immediate mode + local hover = newThemeState == "hover" + local pressed = newThemeState == "pressed" + local focused = newThemeState == "active" or self._focused + + StateManager.updateState(self._stateId, { + hover = hover, + pressed = pressed, + focused = focused, + disabled = self.disabled, + active = self.active, + }) + end + + -- Always update local state for backward compatibility + self._themeState = newThemeState + end + + -- Only process button events if onEvent handler exists, element is not disabled, + -- and this is the topmost element at the mouse position (z-index ordering) + -- Exception: Allow drag continuation even if occluded (once drag starts, it continues) + local isDragging = false + for _, button in ipairs({ 1, 2, 3 }) do + if self._pressed[button] and love.mouse.isDown(button) then + isDragging = true + break + end + end + + local canProcessEvents = (self.onEvent or self.editable) and not self.disabled and (isActiveElement or isDragging) + + if canProcessEvents then + -- Check all three mouse buttons + local buttons = { 1, 2, 3 } -- left, right, middle + + for _, button in ipairs(buttons) do + if isHovering or isDragging then + if love.mouse.isDown(button) then + -- Button is pressed down + if not self._pressed[button] then + -- Check if press is on scrollbar first (skip if already handled) + if button == 1 and not self._scrollbarPressHandled and self:_handleScrollbarPress(mx, my, button) then + -- Scrollbar consumed the event, mark as pressed to prevent onEvent + self._pressed[button] = true + self._scrollbarPressHandled = true + else + -- Just pressed - fire press event and record drag start position + local modifiers = getModifiers() + if self.onEvent then + local pressEvent = InputEvent.new({ + type = "press", + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = 1, + }) + self.onEvent(self, pressEvent) + end + self._pressed[button] = true + + -- Set mouse down position for text selection on left click + if button == 1 and self.editable then + self._mouseDownPosition = self:_mouseToTextPosition(mx, my) + self._textDragOccurred = false -- Reset drag flag on press + end + end + + -- Record drag start position per button + self._dragStartX[button] = mx + self._dragStartY[button] = my + self._lastMouseX[button] = mx + self._lastMouseY[button] = my + else + -- Button is still pressed - check for mouse movement (drag) + local lastX = self._lastMouseX[button] or mx + local lastY = self._lastMouseY[button] or my + + if lastX ~= mx or lastY ~= my then + -- Mouse has moved - fire drag event only if still hovering + if self.onEvent and isHovering then + local modifiers = getModifiers() + local dx = mx - self._dragStartX[button] + local dy = my - self._dragStartY[button] + + local dragEvent = InputEvent.new({ + type = "drag", + button = button, + x = mx, + y = my, + dx = dx, + dy = dy, + modifiers = modifiers, + clickCount = 1, + }) + self.onEvent(self, dragEvent) + end + + -- Handle text selection drag for editable elements + if button == 1 and self.editable and self._focused then + self:_handleTextDrag(mx, my) + end + + -- Update last known position for this button + self._lastMouseX[button] = mx + self._lastMouseY[button] = my + end + end + elseif self._pressed[button] then + -- Button was just released - fire click event + local currentTime = love.timer.getTime() + local modifiers = getModifiers() + + -- Determine click count (double-click detection) + local clickCount = 1 + local doubleClickThreshold = 0.3 -- 300ms for double-click + + if self._lastClickTime and self._lastClickButton == button and (currentTime - self._lastClickTime) < doubleClickThreshold then + clickCount = self._clickCount + 1 + else + clickCount = 1 + end + + self._clickCount = clickCount + self._lastClickTime = currentTime + self._lastClickButton = button + + -- Determine event type based on button + local eventType = "click" + if button == 2 then + eventType = "rightclick" + elseif button == 3 then + eventType = "middleclick" + end + + if self.onEvent then + local clickEvent = InputEvent.new({ + type = eventType, + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = clickCount, + }) + + self.onEvent(self, clickEvent) + end + self._pressed[button] = false + + -- Clean up drag tracking + self._dragStartX[button] = nil + self._dragStartY[button] = nil + + -- Clean up text selection drag tracking + if button == 1 then + self._mouseDownPosition = nil + end + + -- Focus editable elements on left click + if button == 1 and self.editable then + -- Only focus if not already focused (to avoid moving cursor to end) + local wasFocused = self:isFocused() + if not wasFocused then + self:focus() + end + + -- Handle text click for cursor positioning and word selection + -- Only process click if no text drag occurred (to preserve drag selection) + if not self._textDragOccurred then + self:_handleTextClick(mx, my, clickCount) + end + + -- Reset drag flag after release + self._textDragOccurred = false + elseif button == 1 then + end + + -- Fire release event + if self.onEvent then + local releaseEvent = InputEvent.new({ + type = "release", + button = button, + x = mx, + y = my, + modifiers = modifiers, + clickCount = clickCount, + }) + self.onEvent(self, releaseEvent) + end + end + else + -- Mouse left the element - reset pressed state and drag tracking + if self._pressed[button] then + self._pressed[button] = false + self._dragStartX[button] = nil + self._dragStartY[button] = nil + end + end + end + end -- end if self.onEvent + + -- Handle touch events (maintain backward compatibility) + if self.onEvent then + local touches = love.touch.getTouches() + for _, id in ipairs(touches) do + local tx, ty = love.touch.getPosition(id) + if tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh then + self._touchPressed[id] = true + elseif self._touchPressed[id] then + -- Create touch event (treat as left click) + local touchEvent = InputEvent.new({ + type = "click", + button = 1, + x = tx, + y = ty, + modifiers = getModifiers(), + clickCount = 1, + }) + self.onEvent(self, touchEvent) + self._touchPressed[id] = false + end + end + end end end +--- Recalculate units based on new viewport dimensions (for vw, vh, % units) +---@param newViewportWidth number +---@param newViewportHeight number function Element:recalculateUnits(newViewportWidth, newViewportHeight) -- Get updated scale factors local scaleX, scaleY = Gui.getScaleFactors() @@ -2139,6 +3732,9 @@ function Element:recalculateUnits(newViewportWidth, newViewportHeight) self:_detectOverflow() end +--- Resize element and its children based on game window size change +---@param newGameWidth number +---@param newGameHeight number function Element:resize(newGameWidth, newGameHeight) self:recalculateUnits(newGameWidth, newGameHeight) @@ -2216,14 +3812,191 @@ function Element:resize(newGameWidth, newGameHeight) self.prevGameSize.height = newGameHeight end -function Element:updateText(newText, autoresize) - self.text = newText - if self._textEditor then - self._textEditor:setText(newText) +--- Calculate text width for button +---@return number +function Element:calculateTextWidth() + if self.text == nil then + return 0 end - -- TODO: Handle autoresize parameter if needed + + if self.textSize then + -- Resolve font path from font family (same logic as in draw) + 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 + elseif self.themeComponent then + 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 + + local tempFont = FONT_CACHE.get(self.textSize, fontPath) + local width = tempFont:getWidth(self.text) + -- Apply contentAutoSizingMultiplier if set + if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then + width = width * self.contentAutoSizingMultiplier.width + end + return width + end + + local font = love.graphics.getFont() + local width = font:getWidth(self.text) + -- Apply contentAutoSizingMultiplier if set + if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.width then + width = width * self.contentAutoSizingMultiplier.width + end + return width end +---@return number +function Element:calculateTextHeight() + if self.text == nil then + return 0 + end + + -- Get the font + local font + if self.textSize then + -- Resolve font path from font family (same logic as in draw) + 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 + elseif self.themeComponent then + 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 + font = FONT_CACHE.get(self.textSize, fontPath) + else + font = love.graphics.getFont() + end + + local height = font:getHeight() + + -- If text wrapping is enabled, calculate height based on wrapped lines + if self.textWrap and (self.textWrap == "word" or self.textWrap == "char" or self.textWrap == true) then + -- Calculate available width for wrapping + local availableWidth = self.width + + -- If width is not set or is 0, try to use parent's content width + if (not availableWidth or availableWidth <= 0) and self.parent then + -- Use parent's content width (excluding padding) + availableWidth = self.parent.width + end + + if availableWidth and availableWidth > 0 then + -- Get the wrapped text lines using getWrap (returns width and table of lines) + local wrappedWidth, wrappedLines = font:getWrap(self.text, availableWidth) + -- Height is line height * number of lines + height = height * #wrappedLines + end + end + + -- Apply contentAutoSizingMultiplier if set + if self.contentAutoSizingMultiplier and self.contentAutoSizingMultiplier.height then + height = height * self.contentAutoSizingMultiplier.height + end + + return height +end + +function Element:calculateAutoWidth() + -- BORDER-BOX MODEL: Calculate content width, caller will add padding to get border-box + local contentWidth = self:calculateTextWidth() + if not self.children or #self.children == 0 then + return contentWidth + end + + -- For HORIZONTAL flex: sum children widths + gaps + -- For VERTICAL flex: max of children widths + local isHorizontal = self.flexDirection == "horizontal" + local totalWidth = contentWidth + local maxWidth = contentWidth + local participatingChildren = 0 + + for _, child in ipairs(self.children) do + -- Skip explicitly absolute positioned children as they don't affect parent auto-sizing + if not child._explicitlyAbsolute then + -- BORDER-BOX MODEL: Use border-box width for auto-sizing calculations + local childBorderBoxWidth = child:getBorderBoxWidth() + if isHorizontal then + totalWidth = totalWidth + childBorderBoxWidth + else + maxWidth = math.max(maxWidth, childBorderBoxWidth) + end + participatingChildren = participatingChildren + 1 + end + end + + if isHorizontal then + -- Add gaps between children (n-1 gaps for n children) + local gapCount = math.max(0, participatingChildren - 1) + return totalWidth + (self.gap * gapCount) + else + return maxWidth + end +end + +--- Calculate auto height based on children +function Element:calculateAutoHeight() + local height = self:calculateTextHeight() + if not self.children or #self.children == 0 then + return height + end + + -- For VERTICAL flex: sum children heights + gaps + -- For HORIZONTAL flex: max of children heights + local isVertical = self.flexDirection == "vertical" + local totalHeight = height + local maxHeight = height + local participatingChildren = 0 + + for _, child in ipairs(self.children) do + -- Skip explicitly absolute positioned children as they don't affect parent auto-sizing + if not child._explicitlyAbsolute then + -- BORDER-BOX MODEL: Use border-box height for auto-sizing calculations + local childBorderBoxHeight = child:getBorderBoxHeight() + if isVertical then + totalHeight = totalHeight + childBorderBoxHeight + else + maxHeight = math.max(maxHeight, childBorderBoxHeight) + end + participatingChildren = participatingChildren + 1 + end + end + + if isVertical then + -- Add gaps between children (n-1 gaps for n children) + local gapCount = math.max(0, participatingChildren - 1) + return totalHeight + (self.gap * gapCount) + else + return maxHeight + end +end + +---@param newText string +---@param autoresize boolean? --default: false +function Element:updateText(newText, autoresize) + self.text = newText or self.text + if autoresize then + self.width = self:calculateTextWidth() + self.height = self:calculateTextHeight() + end +end + +---@param newOpacity number function Element:updateOpacity(newOpacity) self.opacity = newOpacity for _, child in ipairs(self.children) do @@ -2241,186 +4014,609 @@ function Element:show() self:updateOpacity(1) end +-- ==================== +-- Input Handling - Cursor Management +-- ==================== + +--- Set cursor position +---@param position number -- Character index (0-based) function Element:setCursorPosition(position) - if self._textEditor then - return self._textEditor:setCursorPosition(position) + if not self.editable then + return end + self._cursorPosition = position + self:_validateCursorPosition() + self:_resetCursorBlink() end +--- Get cursor position +---@return number -- Character index (0-based) function Element:getCursorPosition() - if self._textEditor then - return self._textEditor:getCursorPosition() + if not self.editable then + return 0 end + return self._cursorPosition end +--- Move cursor by delta characters +---@param delta number -- Number of characters to move (positive or negative) function Element:moveCursorBy(delta) - if self._textEditor then - return self._textEditor:moveCursorBy(delta) + if not self.editable then + return end + self._cursorPosition = self._cursorPosition + delta + self:_validateCursorPosition() + self:_resetCursorBlink() end +--- Move cursor to start of text function Element:moveCursorToStart() - if self._textEditor then - return self._textEditor:moveCursorToStart() + if not self.editable then + return end + self._cursorPosition = 0 + self:_resetCursorBlink() end +--- Move cursor to end of text function Element:moveCursorToEnd() - if self._textEditor then - return self._textEditor:moveCursorToEnd() + if not self.editable then + return end + local textLength = utf8.len(self._textBuffer or "") + self._cursorPosition = textLength + self:_resetCursorBlink() end +--- Move cursor to start of current line function Element:moveCursorToLineStart() - if self._textEditor then - return self._textEditor:moveCursorToLineStart() + if not self.editable then + return end + -- For now, just move to start (will be enhanced for multi-line) + self:moveCursorToStart() end +--- Move cursor to end of current line function Element:moveCursorToLineEnd() - if self._textEditor then - return self._textEditor:moveCursorToLineEnd() + if not self.editable then + return end + -- For now, just move to end (will be enhanced for multi-line) + self:moveCursorToEnd() end +--- Move cursor to start of previous word function Element:moveCursorToPreviousWord() - if self._textEditor then - return self._textEditor:moveCursorToPreviousWord() + if not self.editable or not self._textBuffer then + return end + + local text = self._textBuffer + local pos = self._cursorPosition + + if pos <= 0 then + return + end + + -- Helper function to get character at position + local function getCharAt(p) + if p < 0 or p >= utf8.len(text) then + return nil + end + local offset1 = utf8.offset(text, p + 1) + local offset2 = utf8.offset(text, p + 2) + if not offset1 then + return nil + end + if not offset2 then + -- Last character in string + return text:sub(offset1) + end + return text:sub(offset1, offset2 - 1) + end + + -- Skip any whitespace/punctuation before current position + while pos > 0 do + local char = getCharAt(pos - 1) + if char and char:match("[%w]") then + break + end + pos = pos - 1 + end + + -- Move to start of current word + while pos > 0 do + local char = getCharAt(pos - 1) + if not char or not char:match("[%w]") then + break + end + pos = pos - 1 + end + + self._cursorPosition = pos + self:_validateCursorPosition() end +--- Move cursor to start of next word function Element:moveCursorToNextWord() - if self._textEditor then - return self._textEditor:moveCursorToNextWord() + if not self.editable or not self._textBuffer then + return end + + local text = self._textBuffer + local textLength = utf8.len(text) or 0 + local pos = self._cursorPosition + + if pos >= textLength then + return + end + + -- Helper function to get character at position + local function getCharAt(p) + if p < 0 or p >= textLength then + return nil + end + local offset1 = utf8.offset(text, p + 1) + local offset2 = utf8.offset(text, p + 2) + if not offset1 then + return nil + end + if not offset2 then + -- Last character in string + return text:sub(offset1) + end + return text:sub(offset1, offset2 - 1) + end + + -- Skip current word + while pos < textLength do + local char = getCharAt(pos) + if not char or not char:match("[%w]") then + break + end + pos = pos + 1 + end + + -- Skip any whitespace/punctuation + while pos < textLength do + local char = getCharAt(pos) + if char and char:match("[%w]") then + break + end + pos = pos + 1 + end + + self._cursorPosition = pos + self:_validateCursorPosition() end +--- Validate cursor position (ensure it's within text bounds) function Element:_validateCursorPosition() - if self._textEditor then - return self._textEditor:_validateCursorPosition() + if not self.editable then + return end + local textLength = utf8.len(self._textBuffer or "") or 0 + local cursorPos = tonumber(self._cursorPosition) or 0 + self._cursorPosition = math.max(0, math.min(cursorPos, textLength)) end +--- Reset cursor blink (show cursor immediately) +---@param pauseBlink boolean|nil -- Whether to pause blinking (for typing) function Element:_resetCursorBlink(pauseBlink) - if self._textEditor then - return self._textEditor:_resetCursorBlink(pauseBlink) + if not self.editable then + return end + self._cursorBlinkTimer = 0 + self._cursorVisible = true + + if pauseBlink then + self._cursorBlinkPaused = true -- Pause blinking while typing + self._cursorBlinkPauseTimer = 0 -- Reset pause timer + end + + -- Update scroll to keep cursor visible + self:_updateTextScroll() end +--- Update text scroll offset to keep cursor visible function Element:_updateTextScroll() - if self._textEditor then - return self._textEditor:_updateTextScroll() + if not self.editable or self.multiline then + return end + + -- Get font for measuring text + local font = self:_getFont() + if not font then + return + end + + -- Calculate cursor X position in text coordinates + local cursorText = "" + if self._textBuffer and self._textBuffer ~= "" and self._cursorPosition > 0 then + local byteOffset = utf8.offset(self._textBuffer, self._cursorPosition + 1) + if byteOffset then + cursorText = self._textBuffer:sub(1, byteOffset - 1) + end + end + local cursorX = font:getWidth(cursorText) + + -- Get available text area width (accounting for padding) + local textAreaWidth = self.width + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Add some padding on the right for the cursor + local cursorPadding = 4 + local visibleWidth = textAreaWidth - cursorPadding + + -- Adjust scroll to keep cursor visible + if cursorX - self._textScrollX < 0 then + -- Cursor is to the left of visible area - scroll left + self._textScrollX = cursorX + elseif cursorX - self._textScrollX > visibleWidth then + -- Cursor is to the right of visible area - scroll right + self._textScrollX = cursorX - visibleWidth + end + + -- Ensure we don't scroll past the beginning + self._textScrollX = math.max(0, self._textScrollX) end +-- ==================== +-- Input Handling - Selection Management +-- ==================== + +--- Set selection range +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) function Element:setSelection(startPos, endPos) - if self._textEditor then - return self._textEditor:setSelection(startPos, endPos) + if not self.editable then + return end + local textLength = utf8.len(self._textBuffer or "") + self._selectionStart = math.max(0, math.min(startPos, textLength)) + self._selectionEnd = math.max(0, math.min(endPos, textLength)) + + -- Ensure start <= end + if self._selectionStart > self._selectionEnd then + self._selectionStart, self._selectionEnd = self._selectionEnd, self._selectionStart + end + + self:_resetCursorBlink() end +--- Get selection range +---@return number?, number? -- Start and end positions, or nil if no selection function Element:getSelection() - if self._textEditor then - return self._textEditor:getSelection() + if not self.editable then + return nil, nil end + if not self:hasSelection() then + return nil, nil + end + return self._selectionStart, self._selectionEnd end +--- Check if there is an active selection +---@return boolean function Element:hasSelection() - if self._textEditor then - return self._textEditor:hasSelection() + if not self.editable then + return false end + return self._selectionStart ~= nil and self._selectionEnd ~= nil and self._selectionStart ~= self._selectionEnd end +--- Clear selection function Element:clearSelection() - if self._textEditor then - return self._textEditor:clearSelection() + if not self.editable then + return end + self._selectionStart = nil + self._selectionEnd = nil + self._selectionAnchor = nil end +--- Select all text function Element:selectAll() - if self._textEditor then - return self._textEditor:selectAll() + if not self.editable then + return end + local textLength = utf8.len(self._textBuffer or "") + self._selectionStart = 0 + self._selectionEnd = textLength + self:_resetCursorBlink() end +--- Get selected text +---@return string? -- Selected text or nil if no selection function Element:getSelectedText() - if self._textEditor then - return self._textEditor:getSelectedText() + if not self.editable or not self:hasSelection() then + return nil end + local startPos, endPos = self:getSelection() + if not startPos or not endPos then + return nil + end + + -- Convert character indices to byte offsets for string.sub + local text = self._textBuffer or "" + local startByte = utf8.offset(text, startPos + 1) + local endByte = utf8.offset(text, endPos + 1) + + if not startByte then + return "" + end + + -- If endByte is nil, it means we want to the end of the string + if endByte then + endByte = endByte - 1 -- Adjust to get the last byte of the character + end + + return string.sub(text, startByte, endByte) end +--- Delete selected text +---@return boolean -- True if text was deleted function Element:deleteSelection() - if self._textEditor then - return self._textEditor:deleteSelection() + if not self.editable or not self:hasSelection() then + return false end + local startPos, endPos = self:getSelection() + if not startPos or not endPos then + return false + end + + self:deleteText(startPos, endPos) + self:clearSelection() + self._cursorPosition = startPos + self:_validateCursorPosition() + + -- Save state to StateManager in immediate mode + self:_saveEditableState() + + return true end +-- ==================== +-- Input Handling - Focus Management +-- ==================== + +--- Focus this element for keyboard input function Element:focus() - if self._textEditor then - return self._textEditor:focus() + if not self.editable then + return end + + if Gui._focusedElement and Gui._focusedElement ~= self then + Gui._focusedElement:blur() + end + + -- Set focus state + self._focused = true + Gui._focusedElement = self + + self:_resetCursorBlink() + + if self.selectOnFocus then + self:selectAll() + else + self:moveCursorToEnd() + end + + -- Trigger onFocus callback if defined + if self.onFocus then + self.onFocus(self) + end + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end +--- Remove focus from this element function Element:blur() - if self._textEditor then - return self._textEditor:blur() + if not self.editable then + return end + + self._focused = false + + -- Clear global focused element if it's this element + if Gui._focusedElement == self then + Gui._focusedElement = nil + end + + -- Trigger onBlur callback if defined + if self.onBlur then + self.onBlur(self) + end + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end +--- Check if this element is focused +---@return boolean function Element:isFocused() - if self._textEditor then - return self._textEditor:isFocused() + if not self.editable then + return false end + return self._focused == true end +--- Save editable element state to StateManager (for immediate mode) function Element:_saveEditableState() - if self._themeManager then - return self._themeManager:_saveEditableState() + if not self.editable or not self._stateId or not Gui._immediateMode then + return end + + StateManager.updateState(self._stateId, { + _focused = self._focused, + _textBuffer = self._textBuffer, + _cursorPosition = self._cursorPosition, + _selectionStart = self._selectionStart, + _selectionEnd = self._selectionEnd, + _cursorBlinkTimer = self._cursorBlinkTimer, + _cursorVisible = self._cursorVisible, + _cursorBlinkPaused = self._cursorBlinkPaused, + _cursorBlinkPauseTimer = self._cursorBlinkPauseTimer, + }) end +-- ==================== +-- Input Handling - Text Buffer Management +-- ==================== + +--- Get current text buffer +---@return string function Element:getText() - if self._textEditor then - return self._textEditor:getText() + if not self.editable then + return self.text or "" end + return self._textBuffer or "" end +--- Set text buffer and mark dirty +---@param text string function Element:setText(text) - if self._textEditor then - return self._textEditor:setText(text) + if not self.editable then + self.text = text + return end + + self._textBuffer = text or "" + self.text = self._textBuffer -- Sync display text + self:_markTextDirty() + self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping + self:_updateAutoGrowHeight() -- Then update height based on new content + self:_validateCursorPosition() + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end +--- Insert text at position +---@param text string -- Text to insert +---@param position number? -- Position to insert at (default: cursor position) function Element:insertText(text, position) - if self._textEditor then - return self._textEditor:insertText(text, position) + if not self.editable then + return end + + position = position or self._cursorPosition + local buffer = self._textBuffer or "" + + -- Check maxLength constraint before inserting + if self.maxLength then + local currentLength = utf8.len(buffer) or 0 + local textLength = utf8.len(text) or 0 + local newLength = currentLength + textLength + + if newLength > self.maxLength then + -- Don't insert if it would exceed maxLength + return + end + end + + -- Convert character position to byte offset + local byteOffset = utf8.offset(buffer, position + 1) or (#buffer + 1) + + -- Insert text + local before = buffer:sub(1, byteOffset - 1) + local after = buffer:sub(byteOffset) + self._textBuffer = before .. text .. after + self.text = self._textBuffer -- Sync display text + + self._cursorPosition = position + utf8.len(text) + + self:_markTextDirty() + self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping + self:_updateAutoGrowHeight() -- Then update height based on new content + self:_validateCursorPosition() + + -- Reset cursor blink to show cursor and pause blinking while typing + self:_resetCursorBlink(true) + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) function Element:deleteText(startPos, endPos) - if self._textEditor then - return self._textEditor:deleteText(startPos, endPos) + if not self.editable then + return end + + local buffer = self._textBuffer or "" + + -- Ensure valid range + local textLength = utf8.len(buffer) + startPos = math.max(0, math.min(startPos, textLength)) + endPos = math.max(0, math.min(endPos, textLength)) + + if startPos > endPos then + startPos, endPos = endPos, startPos + end + + -- Convert character positions to byte offsets + local startByte = utf8.offset(buffer, startPos + 1) or 1 + local endByte = utf8.offset(buffer, endPos + 1) or (#buffer + 1) + + -- Delete text + local before = buffer:sub(1, startByte - 1) + local after = buffer:sub(endByte) + self._textBuffer = before .. after + self.text = self._textBuffer -- Sync display text + + self:_markTextDirty() + self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping + self:_updateAutoGrowHeight() -- Then update height based on new content + + -- Reset cursor blink to show cursor and pause blinking while deleting + self:_resetCursorBlink(true) + + -- Save state to StateManager in immediate mode + self:_saveEditableState() end +--- Replace text in range +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +---@param newText string -- Replacement text function Element:replaceText(startPos, endPos, newText) - if self._textEditor then - return self._textEditor:replaceText(startPos, endPos, newText) + if not self.editable then + return end + + self:deleteText(startPos, endPos) + self:insertText(newText, startPos) end +--- Mark text as dirty (needs recalculation) function Element:_markTextDirty() - if self._textEditor then - return self._textEditor:_markTextDirty() + if not self.editable then + return end + self._textDirty = true end +--- Update text if dirty (recalculate lines and wrapping) function Element:_updateTextIfDirty() - if self._textEditor then - return self._textEditor:_updateTextIfDirty() + if not self.editable or not self._textDirty then + return end + + self:_splitLines() + self:_calculateWrapping() + self:_validateCursorPosition() + self._textDirty = false end +--- Split text into lines (for multi-line text) function Element:_splitLines() if not self.editable then return @@ -2679,18 +4875,283 @@ function Element:_getFont() return FONT_CACHE.getFont(self.textSize, fontPath) end +--- Get cursor screen position for rendering (handles multiline text) +---@return number, number -- Cursor X and Y position relative to content area function Element:_getCursorScreenPosition() - if self._textEditor then - return self._textEditor:_getCursorScreenPosition() + if not self.editable then + return 0, 0 end + + local font = self:_getFont() + if not font then + return 0, 0 + end + + local text = self._textBuffer or "" + local cursorPos = self._cursorPosition or 0 + + -- Apply password masking for cursor position calculation + local textForMeasurement = text + if self.passwordMode and text ~= "" then + textForMeasurement = string.rep("•", utf8.len(text)) + end + + -- For single-line text, calculate simple X position + if not self.multiline then + local cursorText = "" + if textForMeasurement ~= "" and cursorPos > 0 then + local byteOffset = utf8.offset(textForMeasurement, cursorPos + 1) + if byteOffset then + cursorText = textForMeasurement:sub(1, byteOffset - 1) + end + end + return font:getWidth(cursorText), 0 + end + + -- For multiline text, we need to find which wrapped line the cursor is on + -- Update text wrapping if dirty + self:_updateTextIfDirty() + + -- Get text area width for wrapping + local textAreaWidth = self.width + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Split text by actual newlines first + local lines = {} + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + if #lines == 0 then + lines = { "" } + end + + -- Track character position as we iterate through lines + local charCount = 0 + local cursorX = 0 + local cursorY = 0 + local lineHeight = font:getHeight() + + for lineNum, line in ipairs(lines) do + local lineLength = utf8.len(line) or 0 + + -- Check if cursor is on this line (before the newline) + if cursorPos <= charCount + lineLength then + -- Cursor is on this line + local posInLine = cursorPos - charCount + + -- If text wrapping is enabled, find which wrapped segment + if self.textWrap and textAreaWidth > 0 then + local wrappedSegments = self:_wrapLine(line, textAreaWidth) + + for segmentIdx, segment in ipairs(wrappedSegments) do + -- Check if cursor is within this segment's character range + if posInLine >= segment.startIdx and posInLine <= segment.endIdx then + -- Cursor is in this segment + local posInSegment = posInLine - segment.startIdx + local segmentText = "" + if posInSegment > 0 and segment.text ~= "" then + -- Extract substring by character positions using byte offsets + local endByte = utf8.offset(segment.text, posInSegment + 1) + if endByte then + segmentText = segment.text:sub(1, endByte - 1) + else + segmentText = segment.text + end + end + cursorX = font:getWidth(segmentText) + cursorY = (lineNum - 1) * lineHeight + (segmentIdx - 1) * lineHeight + + return cursorX, cursorY + end + end + else + -- No wrapping, simple calculation + local lineText = "" + if posInLine > 0 then + -- Extract substring by character positions using byte offsets + local endByte = utf8.offset(line, posInLine + 1) + if endByte then + lineText = line:sub(1, endByte - 1) + else + lineText = line + end + end + cursorX = font:getWidth(lineText) + cursorY = (lineNum - 1) * lineHeight + return cursorX, cursorY + end + end + + charCount = charCount + lineLength + 1 + end + + -- Cursor is at the very end + return 0, #lines * lineHeight end +--- Get selection rectangles for rendering (handles multiline and wrapped text) +---@param selStart number -- Selection start position (character index) +---@param selEnd number -- Selection end position (character index) +---@return table -- Array of rectangles {x, y, width, height} relative to content area function Element:_getSelectionRects(selStart, selEnd) - if self._textEditor then - return self._textEditor:_getSelectionRects(selStart, selEnd) + if not self.editable then + return {} end + + local font = self:_getFont() + if not font then + return {} + end + + local text = self._textBuffer or "" + local rects = {} + + -- Apply password masking for selection rectangle calculation + local textForMeasurement = text + if self.passwordMode and text ~= "" then + textForMeasurement = string.rep("•", utf8.len(text)) + end + + -- For single-line text, calculate simple rectangle + if not self.multiline then + local startByte = utf8.offset(textForMeasurement, selStart + 1) + local endByte = utf8.offset(textForMeasurement, selEnd + 1) + + if startByte and endByte then + local beforeSelection = textForMeasurement:sub(1, startByte - 1) + local selectedText = textForMeasurement:sub(startByte, endByte - 1) + local selX = font:getWidth(beforeSelection) + local selWidth = font:getWidth(selectedText) + local selY = 0 + local selHeight = font:getHeight() + + table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight }) + end + + return rects + end + + -- For multiline text, we need to handle line wrapping + self:_updateTextIfDirty() + + -- Get text area width for wrapping + local textAreaWidth = self.width + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Split text by actual newlines first + local lines = {} + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + if #lines == 0 then + lines = { "" } + end + + local lineHeight = font:getHeight() + local charCount = 0 + local visualLineNum = 0 + + for lineNum, line in ipairs(lines) do + local lineLength = utf8.len(line) or 0 + + -- Check if selection intersects with this line + local lineStartChar = charCount + local lineEndChar = charCount + lineLength + + if selEnd > lineStartChar and selStart <= lineEndChar then + -- Selection intersects with this line + local selStartInLine = math.max(0, selStart - charCount) + local selEndInLine = math.min(lineLength, selEnd - charCount) + + -- If text wrapping is enabled, handle wrapped segments + if self.textWrap and textAreaWidth > 0 then + local wrappedSegments = self:_wrapLine(line, textAreaWidth) + + for segmentIdx, segment in ipairs(wrappedSegments) do + -- Check if selection intersects with this segment + if selEndInLine > segment.startIdx and selStartInLine <= segment.endIdx then + -- Selection intersects with this segment + local segSelStart = math.max(segment.startIdx, selStartInLine) + local segSelEnd = math.min(segment.endIdx, selEndInLine) + + -- Calculate X position and width + local beforeText = "" + local selectedText = "" + + if segSelStart > segment.startIdx then + local startByte = utf8.offset(segment.text, segSelStart - segment.startIdx + 1) + if startByte then + beforeText = segment.text:sub(1, startByte - 1) + end + end + + local selStartByte = utf8.offset(segment.text, segSelStart - segment.startIdx + 1) + local selEndByte = utf8.offset(segment.text, segSelEnd - segment.startIdx + 1) + if selStartByte and selEndByte then + selectedText = segment.text:sub(selStartByte, selEndByte - 1) + end + + local selX = font:getWidth(beforeText) + local selWidth = font:getWidth(selectedText) + local selY = visualLineNum * lineHeight + local selHeight = lineHeight + + table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight }) + end + + visualLineNum = visualLineNum + 1 + end + else + -- No wrapping, simple calculation + local beforeText = "" + local selectedText = "" + + if selStartInLine > 0 then + local startByte = utf8.offset(line, selStartInLine + 1) + if startByte then + beforeText = line:sub(1, startByte - 1) + end + end + + local selStartByte = utf8.offset(line, selStartInLine + 1) + local selEndByte = utf8.offset(line, selEndInLine + 1) + if selStartByte and selEndByte then + selectedText = line:sub(selStartByte, selEndByte - 1) + end + + local selX = font:getWidth(beforeText) + local selWidth = font:getWidth(selectedText) + local selY = visualLineNum * lineHeight + local selHeight = lineHeight + + table.insert(rects, { x = selX, y = selY, width = selWidth, height = selHeight }) + visualLineNum = visualLineNum + 1 + end + else + -- Selection doesn't intersect, but we still need to count visual lines + if self.textWrap and textAreaWidth > 0 then + local wrappedSegments = self:_wrapLine(line, textAreaWidth) + visualLineNum = visualLineNum + #wrappedSegments + else + visualLineNum = visualLineNum + 1 + end + end + + charCount = charCount + lineLength + 1 + end + + return rects end +--- Update element height based on text content (for autoGrow multiline fields) function Element:_updateAutoGrowHeight() if not self.editable or not self.multiline or not self.autoGrow then return @@ -2749,12 +5210,160 @@ function Element:_updateAutoGrowHeight() end end +-- ==================== +-- Input Handling - Mouse Selection +-- ==================== + +--- Convert mouse coordinates to cursor position in text +---@param mouseX number -- Mouse X coordinate (absolute) +---@param mouseY number -- Mouse Y coordinate (absolute) +---@return number -- Cursor position (character index) function Element:_mouseToTextPosition(mouseX, mouseY) - if self._textEditor then - return self._textEditor:_mouseToTextPosition(mouseX, mouseY) + if not self.editable or not self._textBuffer then + return 0 end + + -- Get content area bounds + local contentX = (self._absoluteX or self.x) + self.padding.left + local contentY = (self._absoluteY or self.y) + self.padding.top + + -- Calculate relative position within text area + local relativeX = mouseX - contentX + local relativeY = mouseY - contentY + + -- Get font for measuring text + local font = self:_getFont() + if not font then + return 0 + end + + local text = self._textBuffer + local textLength = utf8.len(text) or 0 + + -- === SINGLE-LINE TEXT HANDLING === + if not self.multiline then + -- Account for horizontal scroll offset in single-line inputs + if self._textScrollX then + relativeX = relativeX + self._textScrollX + end + + -- Find the character position closest to the click + local closestPos = 0 + local closestDist = math.huge + + -- Check each position in the text + for i = 0, textLength do + -- Get text up to this position + local offset = utf8.offset(text, i + 1) + local beforeText = offset and text:sub(1, offset - 1) or text + local textWidth = font:getWidth(beforeText) + + -- Calculate distance from click to this position + local dist = math.abs(relativeX - textWidth) + + if dist < closestDist then + closestDist = dist + closestPos = i + end + end + + return closestPos + end + + -- === MULTILINE TEXT HANDLING === + + -- Update text wrapping if dirty + self:_updateTextIfDirty() + + -- Split text into lines + local lines = {} + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + if #lines == 0 then + lines = { "" } + end + + local lineHeight = font:getHeight() + + -- Get text area width for wrapping calculations + local textAreaWidth = self.width + local scaledContentPadding = self:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = self._borderBoxWidth or (self.width + self.padding.left + self.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Determine which line the click is on based on Y coordinate + local clickedLineNum = math.floor(relativeY / lineHeight) + 1 + clickedLineNum = math.max(1, math.min(clickedLineNum, #lines)) + + -- Calculate character offset for lines before the clicked line + local charOffset = 0 + for i = 1, clickedLineNum - 1 do + local lineLen = utf8.len(lines[i]) or 0 + charOffset = charOffset + lineLen + 1 -- +1 for newline character + end + + -- Get the clicked line + local clickedLine = lines[clickedLineNum] + local lineLen = utf8.len(clickedLine) or 0 + + -- If text wrapping is enabled, handle wrapped segments + if self.textWrap and textAreaWidth > 0 then + local wrappedSegments = self:_wrapLine(clickedLine, textAreaWidth) + + -- Determine which wrapped segment was clicked + local lineYOffset = (clickedLineNum - 1) * lineHeight + local segmentNum = math.floor((relativeY - lineYOffset) / lineHeight) + 1 + segmentNum = math.max(1, math.min(segmentNum, #wrappedSegments)) + + local segment = wrappedSegments[segmentNum] + + -- Find closest position within the segment + local segmentText = segment.text + local segmentLen = utf8.len(segmentText) or 0 + local closestPos = segment.startIdx + local closestDist = math.huge + + for i = 0, segmentLen do + local offset = utf8.offset(segmentText, i + 1) + local beforeText = offset and segmentText:sub(1, offset - 1) or segmentText + local textWidth = font:getWidth(beforeText) + local dist = math.abs(relativeX - textWidth) + + if dist < closestDist then + closestDist = dist + closestPos = segment.startIdx + i + end + end + + return charOffset + closestPos + end + + -- No wrapping - find closest position in the clicked line + local closestPos = 0 + local closestDist = math.huge + + for i = 0, lineLen do + local offset = utf8.offset(clickedLine, i + 1) + local beforeText = offset and clickedLine:sub(1, offset - 1) or clickedLine + local textWidth = font:getWidth(beforeText) + local dist = math.abs(relativeX - textWidth) + + if dist < closestDist then + closestDist = dist + closestPos = i + end + end + + return charOffset + closestPos end +--- Handle mouse click on text (set cursor position or start selection) +---@param mouseX number -- Mouse X coordinate +---@param mouseY number -- Mouse Y coordinate +---@param clickCount number -- Number of clicks (1=single, 2=double, 3=triple) function Element:_handleTextClick(mouseX, mouseY, clickCount) if not self.editable or not self._focused then return @@ -2801,6 +5410,8 @@ function Element:_handleTextDrag(mouseX, mouseY) self:_resetCursorBlink() end +--- Select word at given position +---@param position number -- Character position function Element:_selectWordAtPosition(position) if not self.editable or not self._textBuffer then return @@ -2846,6 +5457,12 @@ function Element:_selectWordAtPosition(position) end end +-- ==================== +-- Input Handling - Keyboard Input +-- ==================== + +--- Handle text input (character input) +---@param text string -- Character(s) to insert function Element:textinput(text) if not self.editable or not self._focused then return @@ -2881,6 +5498,10 @@ function Element:textinput(text) self:_saveEditableState() end +--- Handle key press (special keys) +---@param key string -- Key name +---@param scancode string -- Scancode +---@param isrepeat boolean -- Whether this is a key repeat function Element:keypressed(key, scancode, isrepeat) if not self.editable or not self._focused then return diff --git a/modules/EventHandler.lua b/modules/EventHandler.lua deleted file mode 100644 index 75cfa37..0000000 --- a/modules/EventHandler.lua +++ /dev/null @@ -1,863 +0,0 @@ --- ==================== --- EventHandler Module --- ==================== --- Extracted event handling functionality from Element.lua --- Handles all mouse, keyboard, touch, and drag events for interactive elements - --- Setup module path for relative requires -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - --- Module dependencies -local GuiState = req("GuiState") -local InputEvent = req("InputEvent") -local StateManager = req("StateManager") -local utils = req("utils") - --- Extract utilities -local getModifiers = utils.getModifiers - --- Reference to Gui (via GuiState) -local Gui = GuiState - ----@class EventHandler ----@field onEvent fun(element:Element, event:InputEvent)? ----@field _pressed table -- Track pressed state per mouse button ----@field _lastClickTime number? -- Timestamp of last click for double-click detection ----@field _lastClickButton number? -- Button of last click ----@field _clickCount number -- Current click count for multi-click detection ----@field _touchPressed table -- Track touch pressed state ----@field _dragStartX table? -- Track drag start X position per mouse button ----@field _dragStartY table? -- Track drag start Y position per mouse button ----@field _lastMouseX table? -- Last known mouse X position per button for drag tracking ----@field _lastMouseY table? -- Last known mouse Y position per button for drag tracking ----@field _scrollbarPressHandled boolean? -- Track if scrollbar press was handled ----@field _element Element? -- Reference to parent element -local EventHandler = {} -EventHandler.__index = EventHandler - ---- Create a new EventHandler instance ----@param config table Configuration options ----@return EventHandler -function EventHandler.new(config) - local self = setmetatable({}, EventHandler) - - -- Configuration - self.onEvent = config.onEvent - - -- Initialize click tracking for event system - self._pressed = {} -- Track pressed state per mouse button - self._lastClickTime = nil - self._lastClickButton = nil - self._clickCount = 0 - self._touchPressed = {} - - -- Initialize drag tracking for event system - self._dragStartX = {} -- Track drag start X position per mouse button - self._dragStartY = {} -- Track drag start Y position per mouse button - self._lastMouseX = {} -- Track last mouse X position per button - self._lastMouseY = {} -- Track last mouse Y position per button - - -- Scrollbar press tracking - self._scrollbarPressHandled = false - - -- Element reference (set via initialize) - self._element = nil - - return self -end - ---- Initialize with parent element reference ----@param element Element The parent element -function EventHandler:initialize(element) - self._element = element - - -- Restore state from StateManager in immediate mode - if Gui._immediateMode and element._stateId then - local state = StateManager.getState(element._stateId) - if state then - -- Restore pressed state - if state._pressed then - self._pressed = state._pressed - end - - -- Restore click tracking - if state._lastClickTime then - self._lastClickTime = state._lastClickTime - end - if state._lastClickButton then - self._lastClickButton = state._lastClickButton - end - if state._clickCount then - self._clickCount = state._clickCount - end - end - end -end - ---- Update event handler state (called every frame) ----@param dt number Delta time -function EventHandler:update(dt) - if not self._element then - return - end - - local element = self._element - local mx, my = love.mouse.getPosition() - - -- Only process events if element has event handler, theme component, or is editable - if not (element.onEvent or element.themeComponent or element.editable) then - return - end - - -- Get element bounds (border box) - local bx = element.x - local by = element.y - local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) - - -- Account for scroll offsets from parent containers - local scrollOffsetX = 0 - local scrollOffsetY = 0 - local current = element.parent - while current do - local overflowX = current.overflowX or current.overflow - local overflowY = current.overflowY or current.overflow - local hasScrollableOverflow = ( - overflowX == "scroll" - or overflowX == "auto" - or overflowY == "scroll" - or overflowY == "auto" - or overflowX == "hidden" - or overflowY == "hidden" - ) - if hasScrollableOverflow then - scrollOffsetX = scrollOffsetX + (current._scrollX or 0) - scrollOffsetY = scrollOffsetY + (current._scrollY or 0) - end - current = current.parent - end - - -- Adjust mouse position by accumulated scroll offset for hit testing - local adjustedMx = mx + scrollOffsetX - local adjustedMy = my + scrollOffsetY - local isHovering = adjustedMx >= bx and adjustedMx <= bx + bw and adjustedMy >= by and adjustedMy <= by + bh - - -- Check if this is the topmost element at the mouse position (z-index ordering) - local isActiveElement - if Gui._immediateMode then - -- In immediate mode, use z-index occlusion detection - local topElement = GuiState.getTopElementAt(mx, my) - isActiveElement = (topElement == element or topElement == nil) - else - -- In retained mode, use the old _activeEventElement mechanism - isActiveElement = (Gui._activeEventElement == nil or Gui._activeEventElement == element) - end - - -- Update theme state based on interaction - if element.themeComponent then - local newThemeState = "normal" - - -- Disabled state takes priority - if element.disabled then - newThemeState = "disabled" - -- Active state (for inputs when focused/typing) - elseif element.active then - newThemeState = "active" - -- Only show hover/pressed states if this element is active (not blocked) - elseif isHovering and isActiveElement then - -- Check if any button is pressed - local anyPressed = false - for _, pressed in pairs(self._pressed) do - if pressed then - anyPressed = true - break - end - end - - if anyPressed then - newThemeState = "pressed" - else - newThemeState = "hover" - end - end - - -- Update state (in StateManager if in immediate mode, otherwise locally) - if element._stateId and Gui._immediateMode then - -- Update in StateManager for immediate mode - local hover = newThemeState == "hover" - local pressed = newThemeState == "pressed" - local focused = newThemeState == "active" or element._focused - - StateManager.updateState(element._stateId, { - hover = hover, - pressed = pressed, - focused = focused, - disabled = element.disabled, - active = element.active, - }) - end - - -- Always update local state for backward compatibility - element._themeState = newThemeState - end - - -- Only process button events if onEvent handler exists, element is not disabled, - -- and this is the topmost element at the mouse position (z-index ordering) - -- Exception: Allow drag continuation even if occluded (once drag starts, it continues) - local isDragging = false - for _, button in ipairs({ 1, 2, 3 }) do - if self._pressed[button] and love.mouse.isDown(button) then - isDragging = true - break - end - end - - local canProcessEvents = (element.onEvent or element.editable) and not element.disabled and (isActiveElement or isDragging) - - if canProcessEvents then - -- Check all three mouse buttons - local buttons = { 1, 2, 3 } -- left, right, middle - - for _, button in ipairs(buttons) do - if isHovering or isDragging then - if love.mouse.isDown(button) then - -- Button is pressed down - if not self._pressed[button] then - -- Check if press is on scrollbar first (skip if already handled) - if button == 1 and not self._scrollbarPressHandled and element._handleScrollbarPress and element:_handleScrollbarPress(mx, my, button) then - -- Scrollbar consumed the event, mark as pressed to prevent onEvent - self._pressed[button] = true - self._scrollbarPressHandled = true - else - -- Just pressed - fire press event and record drag start position - local modifiers = getModifiers() - if element.onEvent then - local pressEvent = InputEvent.new({ - type = "press", - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = 1, - }) - element.onEvent(element, pressEvent) - end - self._pressed[button] = true - - -- Set mouse down position for text selection on left click - if button == 1 and element.editable then - element._mouseDownPosition = element:_mouseToTextPosition(mx, my) - element._textDragOccurred = false -- Reset drag flag on press - end - end - - -- Record drag start position per button - self._dragStartX[button] = mx - self._dragStartY[button] = my - self._lastMouseX[button] = mx - self._lastMouseY[button] = my - else - -- Button is still pressed - check for mouse movement (drag) - local lastX = self._lastMouseX[button] or mx - local lastY = self._lastMouseY[button] or my - - if lastX ~= mx or lastY ~= my then - -- Mouse has moved - fire drag event only if still hovering - if element.onEvent and isHovering then - local modifiers = getModifiers() - local dx = mx - self._dragStartX[button] - local dy = my - self._dragStartY[button] - - local dragEvent = InputEvent.new({ - type = "drag", - button = button, - x = mx, - y = my, - dx = dx, - dy = dy, - modifiers = modifiers, - clickCount = 1, - }) - element.onEvent(element, dragEvent) - end - - -- Handle text selection drag for editable elements - if button == 1 and element.editable and element._focused then - element:_handleTextDrag(mx, my) - end - - -- Update last known position for this button - self._lastMouseX[button] = mx - self._lastMouseY[button] = my - end - end - elseif self._pressed[button] then - -- Button was just released - fire click event - local currentTime = love.timer.getTime() - local modifiers = getModifiers() - - -- Determine click count (double-click detection) - local clickCount = 1 - local doubleClickThreshold = 0.3 -- 300ms for double-click - - if self._lastClickTime and self._lastClickButton == button and (currentTime - self._lastClickTime) < doubleClickThreshold then - clickCount = self._clickCount + 1 - else - clickCount = 1 - end - - self._clickCount = clickCount - self._lastClickTime = currentTime - self._lastClickButton = button - - -- Determine event type based on button - local eventType = "click" - if button == 2 then - eventType = "rightclick" - elseif button == 3 then - eventType = "middleclick" - end - - if element.onEvent then - local clickEvent = InputEvent.new({ - type = eventType, - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = clickCount, - }) - - element.onEvent(element, clickEvent) - end - self._pressed[button] = false - - -- Clean up drag tracking - self._dragStartX[button] = nil - self._dragStartY[button] = nil - - -- Clean up text selection drag tracking - if button == 1 then - element._mouseDownPosition = nil - end - - -- Focus editable elements on left click - if button == 1 and element.editable then - -- Only focus if not already focused (to avoid moving cursor to end) - local wasFocused = element:isFocused() - if not wasFocused then - element:focus() - end - - -- Handle text click for cursor positioning and word selection - -- Only process click if no text drag occurred (to preserve drag selection) - if not element._textDragOccurred then - element:_handleTextClick(mx, my, clickCount) - end - - -- Reset drag flag after release - element._textDragOccurred = false - end - - -- Fire release event - if element.onEvent then - local releaseEvent = InputEvent.new({ - type = "release", - button = button, - x = mx, - y = my, - modifiers = modifiers, - clickCount = clickCount, - }) - element.onEvent(element, releaseEvent) - end - end - else - -- Mouse left the element - reset pressed state and drag tracking - if self._pressed[button] then - self._pressed[button] = false - self._dragStartX[button] = nil - self._dragStartY[button] = nil - end - end - end - end - - -- Handle touch events (maintain backward compatibility) - if element.onEvent then - local touches = love.touch.getTouches() - for _, id in ipairs(touches) do - local tx, ty = love.touch.getPosition(id) - if tx >= bx and tx <= bx + bw and ty >= by and ty <= by + bh then - self._touchPressed[id] = true - elseif self._touchPressed[id] then - -- Create touch event (treat as left click) - local touchEvent = InputEvent.new({ - type = "click", - button = 1, - x = tx, - y = ty, - modifiers = getModifiers(), - clickCount = 1, - }) - element.onEvent(element, touchEvent) - self._touchPressed[id] = false - end - end - end - - -- Save state to StateManager in immediate mode - if element._stateId and Gui._immediateMode then - StateManager.updateState(element._stateId, { - _pressed = self._pressed, - _lastClickTime = self._lastClickTime, - _lastClickButton = self._lastClickButton, - _clickCount = self._clickCount, - }) - end -end - ---- Handle mouse press event ----@param x number Mouse X position ----@param y number Mouse Y position ----@param button number Mouse button (1=left, 2=right, 3=middle) ----@return boolean True if event was consumed -function EventHandler:handleMousePress(x, y, button) - if not self._element then - return false - end - - local element = self._element - - -- Check if element is disabled - if element.disabled then - return false - end - - -- Check if press is within bounds - local bx = element.x - local by = element.y - local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) - - if x < bx or x > bx + bw or y < by or y > by + bh then - return false - end - - -- Fire press event - if element.onEvent then - local modifiers = getModifiers() - local pressEvent = InputEvent.new({ - type = "press", - button = button, - x = x, - y = y, - modifiers = modifiers, - clickCount = 1, - }) - element.onEvent(element, pressEvent) - end - - -- Mark as pressed - self._pressed[button] = true - - -- Record drag start position - self._dragStartX[button] = x - self._dragStartY[button] = y - self._lastMouseX[button] = x - self._lastMouseY[button] = y - - return true -end - ---- Handle mouse release event ----@param x number Mouse X position ----@param y number Mouse Y position ----@param button number Mouse button (1=left, 2=right, 3=middle) ----@return boolean True if event was consumed -function EventHandler:handleMouseRelease(x, y, button) - if not self._element then - return false - end - - local element = self._element - - -- Only handle if button was pressed - if not self._pressed[button] then - return false - end - - -- Fire click event - local currentTime = love.timer.getTime() - local modifiers = getModifiers() - - -- Determine click count (double-click detection) - local clickCount = 1 - local doubleClickThreshold = 0.3 -- 300ms for double-click - - if self._lastClickTime and self._lastClickButton == button and (currentTime - self._lastClickTime) < doubleClickThreshold then - clickCount = self._clickCount + 1 - else - clickCount = 1 - end - - self._clickCount = clickCount - self._lastClickTime = currentTime - self._lastClickButton = button - - -- Determine event type based on button - local eventType = "click" - if button == 2 then - eventType = "rightclick" - elseif button == 3 then - eventType = "middleclick" - end - - if element.onEvent then - local clickEvent = InputEvent.new({ - type = eventType, - button = button, - x = x, - y = y, - modifiers = modifiers, - clickCount = clickCount, - }) - - element.onEvent(element, clickEvent) - end - - -- Mark as released - self._pressed[button] = false - - -- Clean up drag tracking - self._dragStartX[button] = nil - self._dragStartY[button] = nil - - -- Fire release event - if element.onEvent then - local releaseEvent = InputEvent.new({ - type = "release", - button = button, - x = x, - y = y, - modifiers = modifiers, - clickCount = clickCount, - }) - element.onEvent(element, releaseEvent) - end - - return true -end - ---- Handle mouse move event ----@param x number Mouse X position ----@param y number Mouse Y position ----@param dx number Delta X ----@param dy number Delta Y ----@return boolean True if event was consumed -function EventHandler:handleMouseMove(x, y, dx, dy) - if not self._element then - return false - end - - local element = self._element - - -- Check if any button is pressed (drag) - for button, pressed in pairs(self._pressed) do - if pressed then - -- Fire drag event - if element.onEvent then - local modifiers = getModifiers() - local dragDx = x - self._dragStartX[button] - local dragDy = y - self._dragStartY[button] - - local dragEvent = InputEvent.new({ - type = "drag", - button = button, - x = x, - y = y, - dx = dragDx, - dy = dragDy, - modifiers = modifiers, - clickCount = 1, - }) - element.onEvent(element, dragEvent) - end - - -- Update last mouse position - self._lastMouseX[button] = x - self._lastMouseY[button] = y - - return true - end - end - - return false -end - ---- Handle key press event ----@param key string Key name ----@param scancode string Scancode ----@param isrepeat boolean Whether this is a key repeat ----@return boolean True if event was consumed -function EventHandler:handleKeyPress(key, scancode, isrepeat) - if not self._element then - return false - end - - local element = self._element - - -- Only handle if element is focused (for editable elements) - if element.editable and not element._focused then - return false - end - - -- Key events are handled by TextEditor for editable elements - -- This is just a passthrough for custom key handling - if element.onEvent then - local modifiers = getModifiers() - local keyEvent = InputEvent.new({ - type = "keypress", - key = key, - scancode = scancode, - isrepeat = isrepeat, - modifiers = modifiers, - x = 0, - y = 0, - button = 0, - }) - element.onEvent(element, keyEvent) - return true - end - - return false -end - ---- Handle text input event ----@param text string Input text ----@return boolean True if event was consumed -function EventHandler:handleTextInput(text) - if not self._element then - return false - end - - local element = self._element - - -- Only handle if element is focused (for editable elements) - if element.editable and not element._focused then - return false - end - - -- Text input is handled by TextEditor for editable elements - -- This is just a passthrough for custom text handling - if element.onEvent then - local modifiers = getModifiers() - local textEvent = InputEvent.new({ - type = "textinput", - text = text, - modifiers = modifiers, - x = 0, - y = 0, - button = 0, - }) - element.onEvent(element, textEvent) - return true - end - - return false -end - ---- Handle mouse wheel event ----@param x number Horizontal scroll amount ----@param y number Vertical scroll amount ----@return boolean True if event was consumed -function EventHandler:handleWheel(x, y) - if not self._element then - return false - end - - local element = self._element - - -- Fire wheel event - if element.onEvent then - local mx, my = love.mouse.getPosition() - local modifiers = getModifiers() - local wheelEvent = InputEvent.new({ - type = "wheel", - x = mx, - y = my, - dx = x, - dy = y, - modifiers = modifiers, - button = 0, - }) - element.onEvent(element, wheelEvent) - return true - end - - return false -end - ---- Handle touch press event ----@param id any Touch ID ----@param x number Touch X position ----@param y number Touch Y position ----@return boolean True if event was consumed -function EventHandler:handleTouchPress(id, x, y) - if not self._element then - return false - end - - local element = self._element - - -- Check if touch is within bounds - local bx = element.x - local by = element.y - local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) - - if x < bx or x > bx + bw or y < by or y > by + bh then - return false - end - - -- Mark touch as pressed - self._touchPressed[id] = true - - -- Fire touch press event (treat as left click) - if element.onEvent then - local modifiers = getModifiers() - local touchEvent = InputEvent.new({ - type = "press", - button = 1, - x = x, - y = y, - modifiers = modifiers, - clickCount = 1, - }) - element.onEvent(element, touchEvent) - end - - return true -end - ---- Handle touch release event ----@param id any Touch ID ----@param x number Touch X position ----@param y number Touch Y position ----@return boolean True if event was consumed -function EventHandler:handleTouchRelease(id, x, y) - if not self._element then - return false - end - - local element = self._element - - -- Only handle if touch was pressed - if not self._touchPressed[id] then - return false - end - - -- Fire touch release event (treat as left click) - if element.onEvent then - local modifiers = getModifiers() - local touchEvent = InputEvent.new({ - type = "click", - button = 1, - x = x, - y = y, - modifiers = modifiers, - clickCount = 1, - }) - element.onEvent(element, touchEvent) - end - - -- Mark touch as released - self._touchPressed[id] = false - - return true -end - ---- Update hover state based on mouse position ----@param mouseX number Mouse X position ----@param mouseY number Mouse Y position -function EventHandler:updateHoverState(mouseX, mouseY) - if not self._element then - return - end - - local element = self._element - - -- Check if mouse is hovering over element - local bx = element.x - local by = element.y - local bw = element._borderBoxWidth or (element.width + element.padding.left + element.padding.right) - local bh = element._borderBoxHeight or (element.height + element.padding.top + element.padding.bottom) - - local isHovering = mouseX >= bx and mouseX <= bx + bw and mouseY >= by and mouseY <= by + bh - - -- Update hover state in element - if element.themeComponent then - if isHovering then - element._themeState = "hover" - else - element._themeState = "normal" - end - end -end - ---- Dispatch a custom event ----@param event InputEvent The event to dispatch -function EventHandler:dispatchEvent(event) - if not self._element then - return - end - - local element = self._element - - if element.onEvent then - element.onEvent(element, event) - end -end - ---- Check if a mouse button is currently pressed ----@param button number Mouse button (1=left, 2=right, 3=middle) ----@return boolean True if button is pressed -function EventHandler:isButtonPressed(button) - return self._pressed[button] or false -end - ---- Check if element is being dragged ----@return boolean True if element is being dragged -function EventHandler:isDragging() - for _, pressed in pairs(self._pressed) do - if pressed then - return true - end - end - return false -end - ---- Get the current click count ----@return number Click count -function EventHandler:getClickCount() - return self._clickCount -end - ---- Reset all event state -function EventHandler:reset() - self._pressed = {} - self._lastClickTime = nil - self._lastClickButton = nil - self._clickCount = 0 - self._touchPressed = {} - self._dragStartX = {} - self._dragStartY = {} - self._lastMouseX = {} - self._lastMouseY = {} - self._scrollbarPressHandled = false -end - -return EventHandler diff --git a/modules/LayoutEngine.lua b/modules/LayoutEngine.lua deleted file mode 100644 index 776048b..0000000 --- a/modules/LayoutEngine.lua +++ /dev/null @@ -1,634 +0,0 @@ --- ==================== --- LayoutEngine Module --- ==================== --- Extracted layout calculation functionality from Element.lua --- Handles flexbox, grid, absolute/relative positioning, and auto-sizing - --- Setup module path for relative requires -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - --- Module dependencies -local Grid = req("Grid") -local utils = req("utils") - --- Extract enum values -local enums = utils.enums -local Positioning = enums.Positioning -local FlexDirection = enums.FlexDirection -local JustifyContent = enums.JustifyContent -local AlignContent = enums.AlignContent -local AlignItems = enums.AlignItems -local AlignSelf = enums.AlignSelf -local FlexWrap = enums.FlexWrap - ----@class LayoutEngine ----@field element Element -- Reference to parent element ----@field positioning Positioning -- Layout positioning mode ----@field flexDirection FlexDirection -- Direction of flex layout ----@field justifyContent JustifyContent -- Alignment of items along main axis ----@field alignItems AlignItems -- Alignment of items along cross axis ----@field alignContent AlignContent -- Alignment of lines in multi-line flex containers ----@field flexWrap FlexWrap -- Whether children wrap to multiple lines ----@field gridRows number? -- Number of rows in the grid ----@field gridColumns number? -- Number of columns in the grid ----@field columnGap number? -- Gap between grid columns ----@field rowGap number? -- Gap between grid rows -local LayoutEngine = {} -LayoutEngine.__index = LayoutEngine - ---- Create a new LayoutEngine instance ----@param config table -- Configuration options ----@return LayoutEngine -function LayoutEngine.new(config) - local self = setmetatable({}, LayoutEngine) - - -- Store layout configuration - self.positioning = config.positioning or Positioning.RELATIVE - self.flexDirection = config.flexDirection or FlexDirection.HORIZONTAL - self.justifyContent = config.justifyContent or JustifyContent.FLEX_START - self.alignItems = config.alignItems or AlignItems.STRETCH - self.alignContent = config.alignContent or AlignContent.STRETCH - self.flexWrap = config.flexWrap or FlexWrap.NOWRAP - self.gridRows = config.gridRows - self.gridColumns = config.gridColumns - self.columnGap = config.columnGap - self.rowGap = config.rowGap - - -- Element reference (set via initialize) - self.element = nil - - return self -end - ---- Initialize the layout engine with a reference to the parent element ----@param element Element -- The element this layout engine belongs to -function LayoutEngine:initialize(element) - self.element = element -end - ---- Apply positioning offsets (top, right, bottom, left) to an element ----@param child Element -- The child element to apply offsets to -function LayoutEngine:applyPositioningOffsets(child) - if not child or not self.element then - return - end - - local parent = self.element - - -- Only apply offsets to explicitly absolute children or children in relative/absolute containers - -- Flex/grid children ignore positioning offsets as they participate in layout - local isFlexChild = child.positioning == Positioning.FLEX - or child.positioning == Positioning.GRID - or (child.positioning == Positioning.ABSOLUTE and not child._explicitlyAbsolute) - - if not isFlexChild then - -- Apply absolute positioning for explicitly absolute children - -- Apply top offset (distance from parent's content box top edge) - if child.top then - child.y = parent.y + parent.padding.top + child.top - end - - -- Apply bottom offset (distance from parent's content box bottom edge) - -- BORDER-BOX MODEL: Use border-box dimensions for positioning - if child.bottom then - local childBorderBoxHeight = child:getBorderBoxHeight() - child.y = parent.y + parent.padding.top + parent.height - child.bottom - childBorderBoxHeight - end - - -- Apply left offset (distance from parent's content box left edge) - if child.left then - child.x = parent.x + parent.padding.left + child.left - end - - -- Apply right offset (distance from parent's content box right edge) - -- BORDER-BOX MODEL: Use border-box dimensions for positioning - if child.right then - local childBorderBoxWidth = child:getBorderBoxWidth() - child.x = parent.x + parent.padding.left + parent.width - child.right - childBorderBoxWidth - end - end -end - ---- Calculate auto-width based on children and text content ----@return number -- Calculated content width -function LayoutEngine:calculateAutoWidth() - if not self.element then - return 0 - end - - -- BORDER-BOX MODEL: Calculate content width, caller will add padding to get border-box - local contentWidth = self.element:calculateTextWidth() - if not self.element.children or #self.element.children == 0 then - return contentWidth - end - - -- For HORIZONTAL flex: sum children widths + gaps - -- For VERTICAL flex: max of children widths - local isHorizontal = self.flexDirection == FlexDirection.HORIZONTAL - local totalWidth = contentWidth - local maxWidth = contentWidth - local participatingChildren = 0 - - for _, child in ipairs(self.element.children) do - -- Skip explicitly absolute positioned children as they don't affect parent auto-sizing - if not child._explicitlyAbsolute then - -- BORDER-BOX MODEL: Use border-box width for auto-sizing calculations - local childBorderBoxWidth = child:getBorderBoxWidth() - if isHorizontal then - totalWidth = totalWidth + childBorderBoxWidth - else - maxWidth = math.max(maxWidth, childBorderBoxWidth) - end - participatingChildren = participatingChildren + 1 - end - end - - if isHorizontal then - -- Add gaps between children (n-1 gaps for n children) - local gapCount = math.max(0, participatingChildren - 1) - return totalWidth + (self.element.gap * gapCount) - else - return maxWidth - end -end - ---- Calculate auto-height based on children and text content ----@return number -- Calculated content height -function LayoutEngine:calculateAutoHeight() - if not self.element then - return 0 - end - - local height = self.element:calculateTextHeight() - if not self.element.children or #self.element.children == 0 then - return height - end - - -- For VERTICAL flex: sum children heights + gaps - -- For HORIZONTAL flex: max of children heights - local isVertical = self.flexDirection == FlexDirection.VERTICAL - local totalHeight = height - local maxHeight = height - local participatingChildren = 0 - - for _, child in ipairs(self.element.children) do - -- Skip explicitly absolute positioned children as they don't affect parent auto-sizing - if not child._explicitlyAbsolute then - -- BORDER-BOX MODEL: Use border-box height for auto-sizing calculations - local childBorderBoxHeight = child:getBorderBoxHeight() - if isVertical then - totalHeight = totalHeight + childBorderBoxHeight - else - maxHeight = math.max(maxHeight, childBorderBoxHeight) - end - participatingChildren = participatingChildren + 1 - end - end - - if isVertical then - -- Add gaps between children (n-1 gaps for n children) - local gapCount = math.max(0, participatingChildren - 1) - return totalHeight + (self.element.gap * gapCount) - else - return maxHeight - end -end - ---- Main layout calculation - positions all children according to layout mode -function LayoutEngine:layoutChildren() - if not self.element then - return - end - - -- Handle different positioning modes - if self.positioning == Positioning.ABSOLUTE or self.positioning == Positioning.RELATIVE then - -- Absolute/Relative positioned containers don't layout their children according to flex rules, - -- but they should still apply CSS positioning offsets to their children - for _, child in ipairs(self.element.children) do - if child.top or child.right or child.bottom or child.left then - self:applyPositioningOffsets(child) - end - end - return - end - - -- Handle grid layout - if self.positioning == Positioning.GRID then - self:calculateGridLayout() - return - end - - -- Handle flex layout - self:calculateFlexLayout() -end - ---- Calculate grid layout for children -function LayoutEngine:calculateGridLayout() - if not self.element then - return - end - - -- Delegate to Grid module - Grid.layoutGridItems(self.element) -end - ---- Calculate flexbox layout for children -function LayoutEngine:calculateFlexLayout() - if not self.element then - return - end - - local childCount = #self.element.children - - if childCount == 0 then - return - end - - -- Get flex children (children that participate in flex layout) - local flexChildren = {} - for _, child in ipairs(self.element.children) do - local isFlexChild = not (child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute) - if isFlexChild then - table.insert(flexChildren, child) - end - end - - if #flexChildren == 0 then - return - end - - -- Calculate space reserved by absolutely positioned siblings with explicit positioning - local reservedMainStart = 0 -- Space reserved at the start of main axis (left for horizontal, top for vertical) - local reservedMainEnd = 0 -- Space reserved at the end of main axis (right for horizontal, bottom for vertical) - local reservedCrossStart = 0 -- Space reserved at the start of cross axis (top for horizontal, left for vertical) - local reservedCrossEnd = 0 -- Space reserved at the end of cross axis (bottom for horizontal, right for vertical) - - for _, child in ipairs(self.element.children) do - -- Only consider absolutely positioned children with explicit positioning - if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then - -- BORDER-BOX MODEL: Use border-box dimensions for space calculations - local childBorderBoxWidth = child:getBorderBoxWidth() - local childBorderBoxHeight = child:getBorderBoxHeight() - - if self.flexDirection == FlexDirection.HORIZONTAL then - -- Horizontal layout: main axis is X, cross axis is Y - -- Check for left positioning (reserves space at main axis start) - if child.left then - local spaceNeeded = child.left + childBorderBoxWidth - reservedMainStart = math.max(reservedMainStart, spaceNeeded) - end - -- Check for right positioning (reserves space at main axis end) - if child.right then - local spaceNeeded = child.right + childBorderBoxWidth - reservedMainEnd = math.max(reservedMainEnd, spaceNeeded) - end - -- Check for top positioning (reserves space at cross axis start) - if child.top then - local spaceNeeded = child.top + childBorderBoxHeight - reservedCrossStart = math.max(reservedCrossStart, spaceNeeded) - end - -- Check for bottom positioning (reserves space at cross axis end) - if child.bottom then - local spaceNeeded = child.bottom + childBorderBoxHeight - reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded) - end - else - -- Vertical layout: main axis is Y, cross axis is X - -- Check for top positioning (reserves space at main axis start) - if child.top then - local spaceNeeded = child.top + childBorderBoxHeight - reservedMainStart = math.max(reservedMainStart, spaceNeeded) - end - -- Check for bottom positioning (reserves space at main axis end) - if child.bottom then - local spaceNeeded = child.bottom + childBorderBoxHeight - reservedMainEnd = math.max(reservedMainEnd, spaceNeeded) - end - -- Check for left positioning (reserves space at cross axis start) - if child.left then - local spaceNeeded = child.left + childBorderBoxWidth - reservedCrossStart = math.max(reservedCrossStart, spaceNeeded) - end - -- Check for right positioning (reserves space at cross axis end) - if child.right then - local spaceNeeded = child.right + childBorderBoxWidth - reservedCrossEnd = math.max(reservedCrossEnd, spaceNeeded) - end - end - end - end - - -- Calculate available space (accounting for padding and reserved space) - -- BORDER-BOX MODEL: self.element.width and self.element.height are already content dimensions (padding subtracted) - local availableMainSize = 0 - local availableCrossSize = 0 - if self.flexDirection == FlexDirection.HORIZONTAL then - availableMainSize = self.element.width - reservedMainStart - reservedMainEnd - availableCrossSize = self.element.height - reservedCrossStart - reservedCrossEnd - else - availableMainSize = self.element.height - reservedMainStart - reservedMainEnd - availableCrossSize = self.element.width - reservedCrossStart - reservedCrossEnd - end - - -- Handle flex wrap: create lines of children - local lines = {} - - if self.flexWrap == FlexWrap.NOWRAP then - -- All children go on one line - lines[1] = flexChildren - else - -- Wrap children into multiple lines - local currentLine = {} - local currentLineSize = 0 - - for _, child in ipairs(flexChildren) do - -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations - -- Include margins in size calculations - local childMainSize = 0 - local childMainMargin = 0 - if self.flexDirection == FlexDirection.HORIZONTAL then - childMainSize = child:getBorderBoxWidth() - childMainMargin = child.margin.left + child.margin.right - else - childMainSize = child:getBorderBoxHeight() - childMainMargin = child.margin.top + child.margin.bottom - end - local childTotalMainSize = childMainSize + childMainMargin - - -- Check if adding this child would exceed the available space - local lineSpacing = #currentLine > 0 and self.element.gap or 0 - if #currentLine > 0 and currentLineSize + lineSpacing + childTotalMainSize > availableMainSize then - -- Start a new line - if #currentLine > 0 then - table.insert(lines, currentLine) - end - currentLine = { child } - currentLineSize = childTotalMainSize - else - -- Add to current line - table.insert(currentLine, child) - currentLineSize = currentLineSize + lineSpacing + childTotalMainSize - end - end - - -- Add the last line if it has children - if #currentLine > 0 then - table.insert(lines, currentLine) - end - - -- Handle wrap-reverse: reverse the order of lines - if self.flexWrap == FlexWrap.WRAP_REVERSE then - local reversedLines = {} - for i = #lines, 1, -1 do - table.insert(reversedLines, lines[i]) - end - lines = reversedLines - end - end - - -- Calculate line positions and heights (including child padding) - local lineHeights = {} - local totalLinesHeight = 0 - - for lineIndex, line in ipairs(lines) do - local maxCrossSize = 0 - for _, child in ipairs(line) do - -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations - -- Include margins in cross-axis size calculations - local childCrossSize = 0 - local childCrossMargin = 0 - if self.flexDirection == FlexDirection.HORIZONTAL then - childCrossSize = child:getBorderBoxHeight() - childCrossMargin = child.margin.top + child.margin.bottom - else - childCrossSize = child:getBorderBoxWidth() - childCrossMargin = child.margin.left + child.margin.right - end - local childTotalCrossSize = childCrossSize + childCrossMargin - maxCrossSize = math.max(maxCrossSize, childTotalCrossSize) - end - lineHeights[lineIndex] = maxCrossSize - totalLinesHeight = totalLinesHeight + maxCrossSize - end - - -- Account for gaps between lines - local lineGaps = math.max(0, #lines - 1) * self.element.gap - totalLinesHeight = totalLinesHeight + lineGaps - - -- For single line layouts, CENTER, FLEX_END and STRETCH should use full cross size - if #lines == 1 then - if self.alignItems == AlignItems.STRETCH or self.alignItems == AlignItems.CENTER or self.alignItems == AlignItems.FLEX_END then - -- STRETCH, CENTER, and FLEX_END should use full available cross size - lineHeights[1] = availableCrossSize - totalLinesHeight = availableCrossSize - end - -- CENTER and FLEX_END should preserve natural child dimensions - -- and only affect positioning within the available space - end - - -- Calculate starting position for lines based on alignContent - local lineStartPos = 0 - local lineSpacing = self.element.gap - local freeLineSpace = availableCrossSize - totalLinesHeight - - -- Apply AlignContent logic for both single and multiple lines - if self.alignContent == AlignContent.FLEX_START then - lineStartPos = 0 - elseif self.alignContent == AlignContent.CENTER then - lineStartPos = freeLineSpace / 2 - elseif self.alignContent == AlignContent.FLEX_END then - lineStartPos = freeLineSpace - elseif self.alignContent == AlignContent.SPACE_BETWEEN then - lineStartPos = 0 - if #lines > 1 then - lineSpacing = self.element.gap + (freeLineSpace / (#lines - 1)) - end - elseif self.alignContent == AlignContent.SPACE_AROUND then - local spaceAroundEach = freeLineSpace / #lines - lineStartPos = spaceAroundEach / 2 - lineSpacing = self.element.gap + spaceAroundEach - elseif self.alignContent == AlignContent.STRETCH then - lineStartPos = 0 - if #lines > 1 and freeLineSpace > 0 then - lineSpacing = self.element.gap + (freeLineSpace / #lines) - -- Distribute extra space to line heights (only if positive) - local extraPerLine = freeLineSpace / #lines - for i = 1, #lineHeights do - lineHeights[i] = lineHeights[i] + extraPerLine - end - end - end - - -- Position children within each line - local currentCrossPos = lineStartPos - - for lineIndex, line in ipairs(lines) do - local lineHeight = lineHeights[lineIndex] - - -- Calculate total size of children in this line (including padding and margins) - -- BORDER-BOX MODEL: Use border-box dimensions for layout calculations - local totalChildrenSize = 0 - for _, child in ipairs(line) do - if self.flexDirection == FlexDirection.HORIZONTAL then - totalChildrenSize = totalChildrenSize + child:getBorderBoxWidth() + child.margin.left + child.margin.right - else - totalChildrenSize = totalChildrenSize + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom - end - end - - local totalGapSize = math.max(0, #line - 1) * self.element.gap - local totalContentSize = totalChildrenSize + totalGapSize - local freeSpace = availableMainSize - totalContentSize - - -- Calculate initial position and spacing based on justifyContent - local startPos = 0 - local itemSpacing = self.element.gap - - if self.justifyContent == JustifyContent.FLEX_START then - startPos = 0 - elseif self.justifyContent == JustifyContent.CENTER then - startPos = freeSpace / 2 - elseif self.justifyContent == JustifyContent.FLEX_END then - startPos = freeSpace - elseif self.justifyContent == JustifyContent.SPACE_BETWEEN then - startPos = 0 - if #line > 1 then - itemSpacing = self.element.gap + (freeSpace / (#line - 1)) - end - elseif self.justifyContent == JustifyContent.SPACE_AROUND then - local spaceAroundEach = freeSpace / #line - startPos = spaceAroundEach / 2 - itemSpacing = self.element.gap + spaceAroundEach - elseif self.justifyContent == JustifyContent.SPACE_EVENLY then - local spaceBetween = freeSpace / (#line + 1) - startPos = spaceBetween - itemSpacing = self.element.gap + spaceBetween - end - - -- Position children in this line - local currentMainPos = startPos - - for _, child in ipairs(line) do - -- Determine effective cross-axis alignment - local effectiveAlign = child.alignSelf - if effectiveAlign == nil or effectiveAlign == AlignSelf.AUTO then - effectiveAlign = self.alignItems - end - - if self.flexDirection == FlexDirection.HORIZONTAL then - -- Horizontal layout: main axis is X, cross axis is Y - -- Position child at border box (x, y represents top-left including padding) - -- Add reservedMainStart and left margin to account for absolutely positioned siblings and margins - child.x = self.element.x + self.element.padding.left + reservedMainStart + currentMainPos + child.margin.left - - -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations - local childBorderBoxHeight = child:getBorderBoxHeight() - local childTotalCrossSize = childBorderBoxHeight + child.margin.top + child.margin.bottom - - if effectiveAlign == AlignItems.FLEX_START then - child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + child.margin.top - elseif effectiveAlign == AlignItems.CENTER then - child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.top - elseif effectiveAlign == AlignItems.FLEX_END then - child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.top - elseif effectiveAlign == AlignItems.STRETCH then - -- STRETCH: Only apply if height was not explicitly set - if child.autosizing and child.autosizing.height then - -- STRETCH: Set border-box height to lineHeight minus margins, content area shrinks to fit - local availableHeight = lineHeight - child.margin.top - child.margin.bottom - child._borderBoxHeight = availableHeight - child.height = math.max(0, availableHeight - child.padding.top - child.padding.bottom) - end - child.y = self.element.y + self.element.padding.top + reservedCrossStart + currentCrossPos + child.margin.top - end - - -- Apply positioning offsets (top, right, bottom, left) - self:applyPositioningOffsets(child) - - -- If child has children, re-layout them after position change - if #child.children > 0 then - child:layoutChildren() - end - - -- Advance position by child's border-box width plus margins - currentMainPos = currentMainPos + child:getBorderBoxWidth() + child.margin.left + child.margin.right + itemSpacing - else - -- Vertical layout: main axis is Y, cross axis is X - -- Position child at border box (x, y represents top-left including padding) - -- Add reservedMainStart and top margin to account for absolutely positioned siblings and margins - child.y = self.element.y + self.element.padding.top + reservedMainStart + currentMainPos + child.margin.top - - -- BORDER-BOX MODEL: Use border-box dimensions for alignment calculations - local childBorderBoxWidth = child:getBorderBoxWidth() - local childTotalCrossSize = childBorderBoxWidth + child.margin.left + child.margin.right - - if effectiveAlign == AlignItems.FLEX_START then - child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + child.margin.left - elseif effectiveAlign == AlignItems.CENTER then - child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + ((lineHeight - childTotalCrossSize) / 2) + child.margin.left - elseif effectiveAlign == AlignItems.FLEX_END then - child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + lineHeight - childTotalCrossSize + child.margin.left - elseif effectiveAlign == AlignItems.STRETCH then - -- STRETCH: Only apply if width was not explicitly set - if child.autosizing and child.autosizing.width then - -- STRETCH: Set border-box width to lineHeight minus margins, content area shrinks to fit - local availableWidth = lineHeight - child.margin.left - child.margin.right - child._borderBoxWidth = availableWidth - child.width = math.max(0, availableWidth - child.padding.left - child.padding.right) - end - child.x = self.element.x + self.element.padding.left + reservedCrossStart + currentCrossPos + child.margin.left - end - - -- Apply positioning offsets (top, right, bottom, left) - self:applyPositioningOffsets(child) - - -- If child has children, re-layout them after position change - if #child.children > 0 then - child:layoutChildren() - end - - -- Advance position by child's border-box height plus margins - currentMainPos = currentMainPos + child:getBorderBoxHeight() + child.margin.top + child.margin.bottom + itemSpacing - end - end - - -- Move to next line position - currentCrossPos = currentCrossPos + lineHeight + lineSpacing - end - - -- Position explicitly absolute children after flex layout - for _, child in ipairs(self.element.children) do - if child.positioning == Positioning.ABSOLUTE and child._explicitlyAbsolute then - -- Apply positioning offsets (top, right, bottom, left) - self:applyPositioningOffsets(child) - - -- If child has children, layout them after position change - if #child.children > 0 then - child:layoutChildren() - end - end - end - - -- Detect overflow after children are laid out - self.element:_detectOverflow() -end - ---- Get content bounds (position and dimensions of content area) ----@return table -- {x, y, width, height} -function LayoutEngine:getContentBounds() - if not self.element then - return { x = 0, y = 0, width = 0, height = 0 } - end - - return { - x = self.element.x + self.element.padding.left, - y = self.element.y + self.element.padding.top, - width = self.element.width, - height = self.element.height, - } -end - -return LayoutEngine diff --git a/modules/Renderer.lua b/modules/Renderer.lua deleted file mode 100644 index 97766e6..0000000 --- a/modules/Renderer.lua +++ /dev/null @@ -1,478 +0,0 @@ ---[[ -Renderer.lua - Rendering module for FlexLove Element -Handles all visual rendering including backgrounds, borders, images, themes, and effects -]] - --- Setup module path for relative requires -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - --- Module dependencies -local Color = req("Color") -local RoundedRect = req("RoundedRect") -local NinePatch = req("NinePatch") -local ImageRenderer = req("ImageRenderer") -local Blur = req("Blur") -local Theme = req("Theme") -local utils = req("utils") - --- Extract utilities -local FONT_CACHE = utils.FONT_CACHE - --- ==================== --- Renderer Class --- ==================== - ----@class Renderer ----@field element Element -- Reference to parent element ----@field backgroundColor Color -- Background color ----@field borderColor Color -- Border color ----@field opacity number -- Opacity (0-1) ----@field border {top:boolean, right:boolean, bottom:boolean, left:boolean} -- Border sides ----@field cornerRadius {topLeft:number, topRight:number, bottomLeft:number, bottomRight:number} -- Corner radii ----@field theme string? -- Theme name ----@field themeComponent string? -- Theme component name ----@field _themeState string -- Current theme state (normal, hover, pressed, active, disabled) ----@field imagePath string? -- Path to image file ----@field image love.Image? -- Image object ----@field _loadedImage love.Image? -- Cached loaded image ----@field objectFit string -- Image fit mode ----@field objectPosition string -- Image position ----@field imageOpacity number -- Image opacity ----@field contentBlur table? -- Content blur settings ----@field backdropBlur table? -- Backdrop blur settings ----@field _blurInstance table? -- Cached blur instance ----@field scaleCorners number? -- 9-patch corner scale multiplier ----@field scalingAlgorithm string? -- 9-patch scaling algorithm ----@field disableHighlight boolean -- Disable pressed state highlight -local Renderer = {} -Renderer.__index = Renderer - ---- Create a new Renderer instance ----@param config table -- Configuration options ----@return Renderer -function Renderer.new(config) - local self = setmetatable({}, Renderer) - - -- Initialize rendering state - 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 configuration - self.cornerRadius = config.cornerRadius or { - topLeft = 0, - topRight = 0, - bottomLeft = 0, - bottomRight = 0, - } - - -- Theme configuration - self.theme = config.theme - self.themeComponent = config.themeComponent - self._themeState = config._themeState or "normal" - - -- Image configuration - self.imagePath = config.imagePath - self.image = config.image - self._loadedImage = config._loadedImage - self.objectFit = config.objectFit or "fill" - self.objectPosition = config.objectPosition or "center center" - self.imageOpacity = config.imageOpacity or 1 - - -- Blur configuration - self.contentBlur = config.contentBlur - self.backdropBlur = config.backdropBlur - self._blurInstance = config._blurInstance - - -- 9-patch configuration - self.scaleCorners = config.scaleCorners - self.scalingAlgorithm = config.scalingAlgorithm - - -- Visual feedback configuration - self.disableHighlight = config.disableHighlight or false - - -- Element reference (set via initialize) - self.element = nil - - return self -end - ---- Initialize renderer with parent element reference ----@param element Element -function Renderer:initialize(element) - self.element = element -end - ---- Main draw method - orchestrates all rendering ----@param backdropCanvas love.Canvas? -- Canvas for backdrop blur -function Renderer:draw(backdropCanvas) - -- Early exit if element is invisible (optimization) - if self.opacity <= 0 then - return - end - - -- Get element reference for convenience - local element = self.element - if not element then - return - end - - -- 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 = element: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) - if self._loadedImage then - self:drawImage(element.x, element.y, borderBoxWidth, borderBoxHeight) - end - - -- LAYER 2: Draw theme on top of backgroundColor (if theme exists) - if self.themeComponent then - self:drawTheme(element.x, element.y, borderBoxWidth, borderBoxHeight) - end - - -- LAYER 3: Draw borders on top of theme (always render if specified) - self:drawBorder(element.x, element.y, borderBoxWidth, borderBoxHeight) -end - ---- Draw background with corner radius ----@param x number ----@param y number ----@param width number ----@param height number ----@param drawBackgroundColor Color? -- Optional override for background color -function Renderer:drawBackground(x, y, width, height, drawBackgroundColor) - drawBackgroundColor = drawBackgroundColor or self.backgroundColor - - -- Apply opacity to background color - 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 with object-fit modes ----@param x number ----@param y number ----@param borderBoxWidth number ----@param borderBoxHeight number -function Renderer:drawImage(x, y, borderBoxWidth, borderBoxHeight) - if not self._loadedImage or not self.element then - return - end - - local element = self.element - - -- Calculate image bounds (content area - respects padding) - local imageX = x + element.padding.left - local imageY = y + element.padding.top - local imageWidth = element.width - local imageHeight = element.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", 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 component using 9-patch rendering ----@param x number ----@param y number ----@param borderBoxWidth number ----@param borderBoxHeight number -function Renderer:drawTheme(x, y, borderBoxWidth, borderBoxHeight) - if not self.themeComponent or not self.element 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 not atlasToUse or not component.regions then - return - end - - -- 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 not hasAllRegions then - return - end - - -- Pass element-level overrides for scaleCorners and scalingAlgorithm - NinePatch.draw(component, atlasToUse, x, y, borderBoxWidth, borderBoxHeight, self.opacity, self.scaleCorners, self.scalingAlgorithm) -end - ---- Draw borders on specified sides ----@param x number ----@param y number ----@param width number ----@param height number -function Renderer:drawBorder(x, y, width, height) - -- Apply opacity to border color - 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, width, height, self.cornerRadius) - else - -- Draw individual borders (without rounded corners for partial borders) - if self.border.top then - love.graphics.line(x, y, x + width, y) - end - if self.border.bottom then - love.graphics.line(x, y + height, x + width, y + height) - end - if self.border.left then - love.graphics.line(x, y, x, y + height) - end - if self.border.right then - love.graphics.line(x + width, y, x + width, y + height) - end - end -end - ---- Draw pressed state highlight overlay ----@param x number ----@param y number ----@param width number ----@param height number -function Renderer:drawPressedHighlight(x, y, width, height) - if self.disableHighlight or not self.element then - return - end - - local element = self.element - - -- Check if element has onEvent handler - if not element.onEvent then - return - end - - -- Check if any button is pressed - local anyPressed = false - for _, pressed in pairs(element._pressed) do - if pressed then - anyPressed = true - break - end - end - - if anyPressed then - 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, width, height, self.cornerRadius) - end -end - ---- Set background color ----@param color Color -function Renderer:setBackgroundColor(color) - self.backgroundColor = color -end - ---- Set border color ----@param color Color -function Renderer:setBorderColor(color) - self.borderColor = color -end - ---- Set opacity ----@param opacity number -function Renderer:setOpacity(opacity) - self.opacity = opacity -end - ---- Set theme state ----@param state string -function Renderer:setThemeState(state) - self._themeState = state -end - ---- Set loaded image ----@param image love.Image? -function Renderer:setLoadedImage(image) - self._loadedImage = image -end - ---- Get blur instance (delegates to element) ----@return table? -function Renderer:getBlurInstance() - if not self.element then - return nil - end - return self.element:getBlurInstance() -end - ---- Update renderer state from element ---- Call this when element properties change -function Renderer:syncFromElement() - if not self.element then - return - end - - local element = self.element - - -- Sync rendering properties - self.backgroundColor = element.backgroundColor - self.borderColor = element.borderColor - self.opacity = element.opacity - self.border = element.border - self.cornerRadius = element.cornerRadius - self.theme = element.theme - self.themeComponent = element.themeComponent - self._themeState = element._themeState - self.imagePath = element.imagePath - self.image = element.image - self._loadedImage = element._loadedImage - self.objectFit = element.objectFit - self.objectPosition = element.objectPosition - self.imageOpacity = element.imageOpacity - self.contentBlur = element.contentBlur - self.backdropBlur = element.backdropBlur - self._blurInstance = element._blurInstance - self.scaleCorners = element.scaleCorners - self.scalingAlgorithm = element.scalingAlgorithm - self.disableHighlight = element.disableHighlight -end - ---- Update element state from renderer ---- Call this when renderer properties change -function Renderer:syncToElement() - if not self.element then - return - end - - local element = self.element - - -- Sync rendering properties back to element - element.backgroundColor = self.backgroundColor - element.borderColor = self.borderColor - element.opacity = self.opacity - element.border = self.border - element.cornerRadius = self.cornerRadius - element.theme = self.theme - element.themeComponent = self.themeComponent - element._themeState = self._themeState - element.imagePath = self.imagePath - element.image = self.image - element._loadedImage = self._loadedImage - element.objectFit = self.objectFit - element.objectPosition = self.objectPosition - element.imageOpacity = self.imageOpacity - element.contentBlur = self.contentBlur - element.backdropBlur = self.backdropBlur - element._blurInstance = self._blurInstance - element.scaleCorners = self.scaleCorners - element.scalingAlgorithm = self.scalingAlgorithm - element.disableHighlight = self.disableHighlight -end - -return Renderer diff --git a/modules/ScrollManager.lua b/modules/ScrollManager.lua deleted file mode 100644 index 479f34b..0000000 --- a/modules/ScrollManager.lua +++ /dev/null @@ -1,777 +0,0 @@ ---[[ -ScrollManager.lua - Scrolling and overflow management for FlexLove -Handles overflow detection, scrollbar rendering, and scrollbar interaction -Extracted from Element.lua for better modularity and testability -]] - --- ==================== --- Module Setup --- ==================== - --- Setup module path for relative requires -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - --- Module dependencies -local Color = req("Color") - --- ==================== --- Error Handling Utilities --- ==================== - ---- Standardized error message formatter ----@param module string -- Module name ----@param message string -- Error message ----@return string -- Formatted error message -local function formatError(module, message) - return string.format("[FlexLove.%s] %s", module, message) -end - --- ==================== --- ScrollManager Class --- ==================== - ----@class ScrollManager ----@field overflow string -- Overflow mode for both axes ("visible"|"hidden"|"scroll"|"auto") ----@field overflowX string? -- Overflow mode for X axis (overrides overflow) ----@field overflowY string? -- Overflow mode for Y axis (overrides overflow) ----@field scrollbarWidth number -- Width of scrollbar track/thumb ----@field scrollbarColor Color -- Color of scrollbar thumb ----@field scrollbarTrackColor Color -- Color of scrollbar track ----@field scrollbarRadius number -- Corner radius of scrollbar ----@field scrollbarPadding number -- Padding around scrollbar from container edge ----@field scrollSpeed number -- Scroll speed multiplier for wheel events ----@field hideScrollbars {vertical:boolean, horizontal:boolean} -- Hide scrollbars ----@field _element Element? -- Reference to parent element ----@field _overflowX boolean -- Whether content overflows horizontally ----@field _overflowY boolean -- Whether content overflows vertically ----@field _contentWidth number -- Total content width (including overflow) ----@field _contentHeight number -- Total content height (including overflow) ----@field _scrollX number -- Current horizontal scroll position ----@field _scrollY number -- Current vertical scroll position ----@field _maxScrollX number -- Maximum horizontal scroll position ----@field _maxScrollY number -- Maximum vertical scroll position ----@field _scrollbarHoveredVertical boolean -- Whether vertical scrollbar is hovered ----@field _scrollbarHoveredHorizontal boolean -- Whether horizontal scrollbar is hovered ----@field _scrollbarDragging boolean -- Whether a scrollbar is being dragged ----@field _hoveredScrollbar string? -- Which scrollbar is hovered ("vertical"|"horizontal") ----@field _scrollbarDragOffset number -- Offset from thumb top when drag started ----@field _scrollbarPressHandled boolean -- Track if scrollbar press was handled -local ScrollManager = {} -ScrollManager.__index = ScrollManager - ---- Create a new ScrollManager instance ----@param config table -- Configuration options ----@return ScrollManager -function ScrollManager.new(config) - if not config then - error(formatError("ScrollManager", "Configuration table is required")) - end - - local self = setmetatable({}, ScrollManager) - - -- Overflow configuration - self.overflow = config.overflow or "hidden" - self.overflowX = config.overflowX - self.overflowY = config.overflowY - - -- Scrollbar appearance - self.scrollbarWidth = config.scrollbarWidth or 12 - self.scrollbarColor = config.scrollbarColor or Color.new(0.5, 0.5, 0.5, 0.8) - self.scrollbarTrackColor = config.scrollbarTrackColor or Color.new(0.2, 0.2, 0.2, 0.5) - self.scrollbarRadius = config.scrollbarRadius or 6 - self.scrollbarPadding = config.scrollbarPadding or 2 - self.scrollSpeed = config.scrollSpeed or 20 - - -- Validate Color objects - if type(self.scrollbarColor) ~= "table" or not self.scrollbarColor.toRGBA then - error(formatError("ScrollManager", "scrollbarColor must be a Color object")) - end - if type(self.scrollbarTrackColor) ~= "table" or not self.scrollbarTrackColor.toRGBA then - error(formatError("ScrollManager", "scrollbarTrackColor must be a Color object")) - end - - -- hideScrollbars can be boolean or table {vertical: boolean, horizontal: boolean} - if config.hideScrollbars ~= nil then - if type(config.hideScrollbars) == "boolean" then - self.hideScrollbars = { vertical = config.hideScrollbars, horizontal = config.hideScrollbars } - elseif type(config.hideScrollbars) == "table" then - self.hideScrollbars = { - vertical = config.hideScrollbars.vertical ~= nil and config.hideScrollbars.vertical or false, - horizontal = config.hideScrollbars.horizontal ~= nil and config.hideScrollbars.horizontal or false, - } - else - self.hideScrollbars = { vertical = false, horizontal = false } - end - else - self.hideScrollbars = { vertical = false, horizontal = false } - end - - -- Internal state - self._element = nil - self._overflowX = false - self._overflowY = false - self._contentWidth = 0 - self._contentHeight = 0 - self._scrollX = config._scrollX or 0 - self._scrollY = config._scrollY or 0 - self._maxScrollX = 0 - self._maxScrollY = 0 - - -- Scrollbar interaction state - self._scrollbarHoveredVertical = false - self._scrollbarHoveredHorizontal = false - self._scrollbarDragging = false - self._hoveredScrollbar = nil - self._scrollbarDragOffset = 0 - self._scrollbarPressHandled = false - - return self -end - ---- Initialize ScrollManager with parent element reference ----@param element Element -- Parent element -function ScrollManager:initialize(element) - if not element then - error(formatError("ScrollManager", "Element reference is required")) - end - self._element = element -end - ---- Detect if content overflows container bounds ---- Calculates content dimensions and overflow state based on children -function ScrollManager:detectOverflow() - if not self._element then - error(formatError("ScrollManager", "ScrollManager not initialized with element")) - end - - -- Reset overflow state - self._overflowX = false - self._overflowY = false - self._contentWidth = self._element.width - self._contentHeight = self._element.height - - -- Skip detection if overflow is visible (no clipping needed) - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - if overflowX == "visible" and overflowY == "visible" then - return - end - - -- Calculate content bounds based on children - if #self._element.children == 0 then - return -- No children, no overflow - end - - local minX, minY = 0, 0 - local maxX, maxY = 0, 0 - - -- Content area starts after padding - local contentX = self._element.x + self._element.padding.left - local contentY = self._element.y + self._element.padding.top - - for _, child in ipairs(self._element.children) do - -- Skip absolutely positioned children (they don't contribute to overflow) - if not child._explicitlyAbsolute then - -- Calculate child position relative to content area - local childLeft = child.x - contentX - local childTop = child.y - contentY - local childRight = childLeft + child:getBorderBoxWidth() + child.margin.right - local childBottom = childTop + child:getBorderBoxHeight() + child.margin.bottom - - maxX = math.max(maxX, childRight) - maxY = math.max(maxY, childBottom) - end - end - - -- Calculate content dimensions - self._contentWidth = maxX - self._contentHeight = maxY - - -- Detect overflow - local containerWidth = self._element.width - local containerHeight = self._element.height - - self._overflowX = self._contentWidth > containerWidth - self._overflowY = self._contentHeight > containerHeight - - -- Calculate maximum scroll bounds - self._maxScrollX = math.max(0, self._contentWidth - containerWidth) - self._maxScrollY = math.max(0, self._contentHeight - containerHeight) - - -- Clamp current scroll position to new bounds - self._scrollX = math.max(0, math.min(self._scrollX, self._maxScrollX)) - self._scrollY = math.max(0, math.min(self._scrollY, self._maxScrollY)) -end - ---- Set scroll position with bounds clamping ----@param x number? -- X scroll position (nil to keep current) ----@param y number? -- Y scroll position (nil to keep current) -function ScrollManager:setScroll(x, y) - if x ~= nil then - if type(x) ~= "number" then - error(formatError("ScrollManager", "Scroll X position must be a number")) - end - self._scrollX = math.max(0, math.min(x, self._maxScrollX)) - end - if y ~= nil then - if type(y) ~= "number" then - error(formatError("ScrollManager", "Scroll Y position must be a number")) - end - self._scrollY = math.max(0, math.min(y, self._maxScrollY)) - end -end - ---- Get current scroll position ----@return number scrollX, number scrollY -function ScrollManager:getScroll() - return self._scrollX, self._scrollY -end - ---- Scroll by delta amount (relative scrolling) ----@param dx number? -- X delta (nil for no change) ----@param dy number? -- Y delta (nil for no change) -function ScrollManager:scroll(dx, dy) - if dx ~= nil then - if type(dx) ~= "number" then - error(formatError("ScrollManager", "Scroll delta X must be a number")) - end - self._scrollX = math.max(0, math.min(self._scrollX + dx, self._maxScrollX)) - end - if dy ~= nil then - if type(dy) ~= "number" then - error(formatError("ScrollManager", "Scroll delta Y must be a number")) - end - self._scrollY = math.max(0, math.min(self._scrollY + dy, self._maxScrollY)) - end -end - ---- Calculate scrollbar dimensions and positions ----@return table -- {vertical: {visible, trackHeight, thumbHeight, thumbY}, horizontal: {visible, trackWidth, thumbWidth, thumbX}} -function ScrollManager:calculateScrollbarDimensions() - if not self._element then - error(formatError("ScrollManager", "ScrollManager not initialized with element")) - end - - local result = { - vertical = { visible = false, trackHeight = 0, thumbHeight = 0, thumbY = 0 }, - horizontal = { visible = false, trackWidth = 0, thumbWidth = 0, thumbX = 0 }, - } - - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - -- Vertical scrollbar - -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows - if overflowY == "scroll" then - -- Always show scrollbar for "scroll" mode - result.vertical.visible = true - result.vertical.trackHeight = self._element.height - (self.scrollbarPadding * 2) - - if self._overflowY then - -- Content overflows, calculate proper thumb size - local contentRatio = self._element.height / math.max(self._contentHeight, self._element.height) - result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) - - -- Calculate thumb position based on scroll ratio - local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 - local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight - result.vertical.thumbY = maxThumbY * scrollRatio - else - -- No overflow, thumb fills entire track - result.vertical.thumbHeight = result.vertical.trackHeight - result.vertical.thumbY = 0 - end - elseif self._overflowY and overflowY == "auto" then - -- Only show scrollbar when content actually overflows - result.vertical.visible = true - result.vertical.trackHeight = self._element.height - (self.scrollbarPadding * 2) - - -- Calculate thumb height based on content ratio - local contentRatio = self._element.height / math.max(self._contentHeight, self._element.height) - result.vertical.thumbHeight = math.max(20, result.vertical.trackHeight * contentRatio) - - -- Calculate thumb position based on scroll ratio - local scrollRatio = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 - local maxThumbY = result.vertical.trackHeight - result.vertical.thumbHeight - result.vertical.thumbY = maxThumbY * scrollRatio - end - - -- Horizontal scrollbar - -- Note: overflow="scroll" always shows scrollbar; overflow="auto" only when content overflows - if overflowX == "scroll" then - -- Always show scrollbar for "scroll" mode - result.horizontal.visible = true - result.horizontal.trackWidth = self._element.width - (self.scrollbarPadding * 2) - - if self._overflowX then - -- Content overflows, calculate proper thumb size - local contentRatio = self._element.width / math.max(self._contentWidth, self._element.width) - result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) - - -- Calculate thumb position based on scroll ratio - local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 - local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth - result.horizontal.thumbX = maxThumbX * scrollRatio - else - -- No overflow, thumb fills entire track - result.horizontal.thumbWidth = result.horizontal.trackWidth - result.horizontal.thumbX = 0 - end - elseif self._overflowX and overflowX == "auto" then - -- Only show scrollbar when content actually overflows - result.horizontal.visible = true - result.horizontal.trackWidth = self._element.width - (self.scrollbarPadding * 2) - - -- Calculate thumb width based on content ratio - local contentRatio = self._element.width / math.max(self._contentWidth, self._element.width) - result.horizontal.thumbWidth = math.max(20, result.horizontal.trackWidth * contentRatio) - - -- Calculate thumb position based on scroll ratio - local scrollRatio = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 - local maxThumbX = result.horizontal.trackWidth - result.horizontal.thumbWidth - result.horizontal.thumbX = maxThumbX * scrollRatio - end - - return result -end - ---- Draw scrollbars ----@param x number -- Element X position ----@param y number -- Element Y position ----@param width number -- Element width ----@param height number -- Element height -function ScrollManager:drawScrollbars(x, y, width, height) - if not self._element then - error(formatError("ScrollManager", "ScrollManager not initialized with element")) - end - - local dims = self:calculateScrollbarDimensions() - - -- 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._element.padding.left - local contentY = y + self._element.padding.top - local trackX = contentX + width - 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._element.padding.left - local contentY = y + self._element.padding.top - local trackX = contentX + self.scrollbarPadding - local trackY = contentY + height - 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 ----@param mouseY number ----@return table|nil -- {component: "vertical"|"horizontal", region: "thumb"|"track"} -function ScrollManager:_getScrollbarAtPosition(mouseX, mouseY) - if not self._element then - return nil - end - - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then - return nil - end - - local dims = self:calculateScrollbarDimensions() - local x, y = self._element.x, self._element.y - local w, h = self._element.width, self._element.height - - -- Check vertical scrollbar (only if not hidden) - 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._element.padding.left - local contentY = y + self._element.padding.top - local trackX = contentX + w - self.scrollbarWidth - self.scrollbarPadding - local trackY = contentY + self.scrollbarPadding - local trackW = self.scrollbarWidth - local trackH = dims.vertical.trackHeight - - if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then - -- Check if over thumb - local thumbY = trackY + dims.vertical.thumbY - local thumbH = dims.vertical.thumbHeight - if mouseY >= thumbY and mouseY <= thumbY + thumbH then - return { component = "vertical", region = "thumb" } - else - return { component = "vertical", region = "track" } - end - end - end - - -- Check horizontal scrollbar (only if not hidden) - 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._element.padding.left - local contentY = y + self._element.padding.top - local trackX = contentX + self.scrollbarPadding - local trackY = contentY + h - self.scrollbarWidth - self.scrollbarPadding - local trackW = dims.horizontal.trackWidth - local trackH = self.scrollbarWidth - - if mouseX >= trackX and mouseX <= trackX + trackW and mouseY >= trackY and mouseY <= trackY + trackH then - -- Check if over thumb - local thumbX = trackX + dims.horizontal.thumbX - local thumbW = dims.horizontal.thumbWidth - if mouseX >= thumbX and mouseX <= thumbX + thumbW then - return { component = "horizontal", region = "thumb" } - else - return { component = "horizontal", region = "track" } - end - end - end - - return nil -end - ---- Handle scrollbar mouse press ----@param mouseX number ----@param mouseY number ----@param button number ----@return boolean -- True if event was consumed -function ScrollManager:handleMousePress(mouseX, mouseY, button) - if button ~= 1 then - return false - end -- Only left click - - local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY) - if not scrollbar then - return false - end - - if scrollbar.region == "thumb" then - -- Start dragging thumb - self._scrollbarDragging = true - self._hoveredScrollbar = scrollbar.component - local dims = self:calculateScrollbarDimensions() - - if scrollbar.component == "vertical" then - local contentY = self._element.y + self._element.padding.top - local trackY = contentY + self.scrollbarPadding - local thumbY = trackY + dims.vertical.thumbY - self._scrollbarDragOffset = mouseY - thumbY - elseif scrollbar.component == "horizontal" then - local contentX = self._element.x + self._element.padding.left - local trackX = contentX + self.scrollbarPadding - local thumbX = trackX + dims.horizontal.thumbX - self._scrollbarDragOffset = mouseX - thumbX - end - - return true -- Event consumed - elseif scrollbar.region == "track" then - -- Click on track - jump to position - self:_scrollToTrackPosition(mouseX, mouseY, scrollbar.component) - return true - end - - return false -end - ---- Handle scrollbar release ----@param mouseX number ----@param mouseY number ----@param button number ----@return boolean -- True if event was consumed -function ScrollManager:handleMouseRelease(mouseX, mouseY, button) - if button ~= 1 then - return false - end - - if self._scrollbarDragging then - self._scrollbarDragging = false - return true - end - - return false -end - ---- Handle scrollbar drag ----@param mouseX number ----@param mouseY number ----@return boolean -- True if event was consumed -function ScrollManager:handleMouseMove(mouseX, mouseY) - if not self._scrollbarDragging then - return false - end - - local dims = self:calculateScrollbarDimensions() - - if self._hoveredScrollbar == "vertical" then - local contentY = self._element.y + self._element.padding.top - local trackY = contentY + self.scrollbarPadding - local trackH = dims.vertical.trackHeight - local thumbH = dims.vertical.thumbHeight - - -- Calculate new thumb position - local newThumbY = mouseY - self._scrollbarDragOffset - trackY - newThumbY = math.max(0, math.min(newThumbY, trackH - thumbH)) - - -- Convert thumb position to scroll position - local scrollRatio = (trackH - thumbH) > 0 and (newThumbY / (trackH - thumbH)) or 0 - local newScrollY = scrollRatio * self._maxScrollY - - self:setScroll(nil, newScrollY) - return true - elseif self._hoveredScrollbar == "horizontal" then - local contentX = self._element.x + self._element.padding.left - local trackX = contentX + self.scrollbarPadding - local trackW = dims.horizontal.trackWidth - local thumbW = dims.horizontal.thumbWidth - - -- Calculate new thumb position - local newThumbX = mouseX - self._scrollbarDragOffset - trackX - newThumbX = math.max(0, math.min(newThumbX, trackW - thumbW)) - - -- Convert thumb position to scroll position - local scrollRatio = (trackW - thumbW) > 0 and (newThumbX / (trackW - thumbW)) or 0 - local newScrollX = scrollRatio * self._maxScrollX - - self:setScroll(newScrollX, nil) - return true - end - - return false -end - ---- Handle mouse wheel scrolling ----@param x number -- Horizontal scroll amount ----@param y number -- Vertical scroll amount ----@return boolean -- True if scroll was handled -function ScrollManager:handleWheel(x, y) - local overflowX = self.overflowX or self.overflow - local overflowY = self.overflowY or self.overflow - - if not (overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto") then - return false - end - - local hasVerticalOverflow = self._overflowY and self._maxScrollY > 0 - local hasHorizontalOverflow = self._overflowX and self._maxScrollX > 0 - - local scrolled = false - - -- Vertical scrolling - if y ~= 0 and hasVerticalOverflow then - local delta = -y * self.scrollSpeed -- Negative because wheel up = scroll up - local newScrollY = self._scrollY + delta - self:setScroll(nil, newScrollY) - scrolled = true - end - - -- Horizontal scrolling - if x ~= 0 and hasHorizontalOverflow then - local delta = -x * self.scrollSpeed - local newScrollX = self._scrollX + delta - self:setScroll(newScrollX, nil) - scrolled = true - end - - return scrolled -end - ---- Check if scrollbar is hovered at position ----@param mouseX number ----@param mouseY number ----@return boolean vertical, boolean horizontal -function ScrollManager:isScrollbarHovered(mouseX, mouseY) - local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY) - if not scrollbar then - return false, false - end - return scrollbar.component == "vertical", scrollbar.component == "horizontal" -end - ---- Get content bounds and scroll limits ----@return number contentWidth, number contentHeight, number maxScrollX, number maxScrollY -function ScrollManager:getContentBounds() - return self._contentWidth, self._contentHeight, self._maxScrollX, self._maxScrollY -end - ---- Update scrollbar state (called each frame) ----@param dt number -- Delta time ----@param mouseX number -- Current mouse X position ----@param mouseY number -- Current mouse Y position -function ScrollManager:update(dt, mouseX, mouseY) - local scrollbar = self:_getScrollbarAtPosition(mouseX, mouseY) - - -- Update independent hover states for vertical and horizontal scrollbars - if scrollbar and scrollbar.component == "vertical" then - self._scrollbarHoveredVertical = true - self._hoveredScrollbar = "vertical" - else - if not (self._scrollbarDragging and self._hoveredScrollbar == "vertical") then - self._scrollbarHoveredVertical = false - end - end - - if scrollbar and scrollbar.component == "horizontal" then - self._scrollbarHoveredHorizontal = true - self._hoveredScrollbar = "horizontal" - else - if not (self._scrollbarDragging and self._hoveredScrollbar == "horizontal") then - self._scrollbarHoveredHorizontal = false - end - end - - -- Clear hoveredScrollbar if neither is hovered - if not scrollbar and not self._scrollbarDragging then - self._hoveredScrollbar = nil - end -end - ---- Scroll to track click position (jump to position) ----@param mouseX number ----@param mouseY number ----@param component string -- "vertical" or "horizontal" -function ScrollManager:_scrollToTrackPosition(mouseX, mouseY, component) - local dims = self:calculateScrollbarDimensions() - - if component == "vertical" then - local contentY = self._element.y + self._element.padding.top - local trackY = contentY + self.scrollbarPadding - local trackH = dims.vertical.trackHeight - local thumbH = dims.vertical.thumbHeight - - -- Calculate target thumb position (centered on click) - local targetThumbY = mouseY - trackY - (thumbH / 2) - targetThumbY = math.max(0, math.min(targetThumbY, trackH - thumbH)) - - -- Convert to scroll position - local scrollRatio = (trackH - thumbH) > 0 and (targetThumbY / (trackH - thumbH)) or 0 - local newScrollY = scrollRatio * self._maxScrollY - - self:setScroll(nil, newScrollY) - elseif component == "horizontal" then - local contentX = self._element.x + self._element.padding.left - local trackX = contentX + self.scrollbarPadding - local trackW = dims.horizontal.trackWidth - local thumbW = dims.horizontal.thumbWidth - - -- Calculate target thumb position (centered on click) - local targetThumbX = mouseX - trackX - (thumbW / 2) - targetThumbX = math.max(0, math.min(targetThumbX, trackW - thumbW)) - - -- Convert to scroll position - local scrollRatio = (trackW - thumbW) > 0 and (targetThumbX / (trackW - thumbW)) or 0 - local newScrollX = scrollRatio * self._maxScrollX - - self:setScroll(newScrollX, nil) - end -end - ---- Get current scrollbar dragging state ----@return boolean dragging, string? component -function ScrollManager:getDraggingState() - return self._scrollbarDragging, self._hoveredScrollbar -end - ---- Set scrollbar dragging state (for state restoration) ----@param dragging boolean ----@param component string? -- "vertical" or "horizontal" ----@param dragOffset number? -function ScrollManager:setDraggingState(dragging, component, dragOffset) - self._scrollbarDragging = dragging - self._hoveredScrollbar = component - self._scrollbarDragOffset = dragOffset or 0 -end - ---- Get scrollbar hover state ----@return boolean vertical, boolean horizontal -function ScrollManager:getHoverState() - return self._scrollbarHoveredVertical, self._scrollbarHoveredHorizontal -end - ---- Set scrollbar hover state (for state restoration) ----@param vertical boolean ----@param horizontal boolean -function ScrollManager:setHoverState(vertical, horizontal) - self._scrollbarHoveredVertical = vertical - self._scrollbarHoveredHorizontal = horizontal -end - ---- Check if element has overflow ----@return boolean hasOverflowX, boolean hasOverflowY -function ScrollManager:hasOverflow() - return self._overflowX, self._overflowY -end - ---- Get scroll percentage (0-1) ----@return number percentX, number percentY -function ScrollManager:getScrollPercentage() - local percentX = self._maxScrollX > 0 and (self._scrollX / self._maxScrollX) or 0 - local percentY = self._maxScrollY > 0 and (self._scrollY / self._maxScrollY) or 0 - return percentX, percentY -end - ---- Scroll to top -function ScrollManager:scrollToTop() - self:setScroll(nil, 0) -end - ---- Scroll to bottom -function ScrollManager:scrollToBottom() - self:setScroll(nil, self._maxScrollY) -end - ---- Scroll to left -function ScrollManager:scrollToLeft() - self:setScroll(0, nil) -end - ---- Scroll to right -function ScrollManager:scrollToRight() - self:setScroll(self._maxScrollX, nil) -end - -return ScrollManager diff --git a/modules/TextEditor.lua b/modules/TextEditor.lua index a0b10e0..dffa643 100644 --- a/modules/TextEditor.lua +++ b/modules/TextEditor.lua @@ -1,8 +1,14 @@ -- ==================== -- TextEditor Module -- ==================== --- Extracted text editing functionality from Element.lua --- Handles all text input, cursor management, selection, and text rendering for editable elements +-- Handles all text editing functionality including: +-- - Text buffer management +-- - Cursor positioning and navigation +-- - Text selection +-- - Multi-line text with wrapping +-- - Focus management +-- - Keyboard input handling +-- - Text rendering (cursor, selection highlights) -- Setup module path for relative requires local modulePath = (...):match("(.-)[^%.]+$") @@ -20,56 +26,36 @@ local utils = req("utils") local FONT_CACHE = utils.FONT_CACHE local getModifiers = utils.getModifiers --- Reference to Gui (via GuiState) -local Gui = GuiState - --- UTF-8 support (available in LÖVE/Lua 5.3+) +-- UTF-8 support local utf8 = utf8 or require("utf8") ----@class TextEditor ----@field editable boolean ----@field multiline boolean ----@field passwordMode boolean ----@field textWrap boolean|"word"|"char" ----@field maxLines number? ----@field maxLength number? ----@field placeholder string? ----@field inputType "text"|"number"|"email"|"url" ----@field textOverflow "clip"|"ellipsis"|"scroll" ----@field scrollable boolean ----@field autoGrow boolean ----@field selectOnFocus boolean ----@field cursorColor Color? ----@field selectionColor Color? ----@field cursorBlinkRate number ----@field _cursorPosition number ----@field _cursorLine number ----@field _cursorColumn number ----@field _cursorBlinkTimer number ----@field _cursorVisible boolean ----@field _cursorBlinkPaused boolean ----@field _cursorBlinkPauseTimer number ----@field _selectionStart number? ----@field _selectionEnd number? ----@field _selectionAnchor number? ----@field _focused boolean ----@field _textBuffer string ----@field _lines table? ----@field _wrappedLines table? ----@field _textDirty boolean ----@field _textScrollX number ----@field _mouseDownPosition number? ----@field _element Element? local TextEditor = {} TextEditor.__index = TextEditor ---- Create a new TextEditor instance ----@param config table Configuration options ----@return TextEditor +---@class TextEditorConfig +---@field editable boolean -- Whether text is editable +---@field multiline boolean -- Whether multi-line is supported +---@field passwordMode boolean -- Whether to mask text +---@field textWrap boolean|"word"|"char" -- Text wrapping mode +---@field maxLines number? -- Maximum number of lines +---@field maxLength number? -- Maximum text length in characters +---@field placeholder string? -- Placeholder text when empty +---@field inputType "text"|"number"|"email"|"url" -- Input validation type +---@field textOverflow "clip"|"ellipsis"|"scroll" -- Text overflow behavior +---@field scrollable boolean -- Whether text is scrollable +---@field autoGrow boolean -- Whether element auto-grows with text +---@field selectOnFocus boolean -- Whether to select all text on focus +---@field cursorColor Color? -- Cursor color +---@field selectionColor Color? -- Selection background color +---@field cursorBlinkRate number -- Cursor blink rate in seconds + +---Create a new TextEditor instance +---@param config TextEditorConfig +---@return table TextEditor instance function TextEditor.new(config) local self = setmetatable({}, TextEditor) - -- Configuration + -- Store configuration self.editable = config.editable or false self.multiline = config.multiline or false self.passwordMode = config.passwordMode or false @@ -86,7 +72,13 @@ function TextEditor.new(config) self.selectionColor = config.selectionColor self.cursorBlinkRate = config.cursorBlinkRate or 0.5 - -- Initialize cursor and selection state + -- Initialize text buffer state + self._textBuffer = config.text or "" + self._lines = nil + self._wrappedLines = nil + self._textDirty = true + + -- Initialize cursor state self._cursorPosition = 0 self._cursorLine = 1 self._cursorColumn = 0 @@ -95,25 +87,23 @@ function TextEditor.new(config) self._cursorBlinkPaused = false self._cursorBlinkPauseTimer = 0 - -- Selection state + -- Initialize selection state self._selectionStart = nil self._selectionEnd = nil self._selectionAnchor = nil - -- Focus state + -- Initialize focus state self._focused = false - -- Text buffer state - self._textBuffer = config.text or "" - self._lines = nil - self._wrappedLines = nil - self._textDirty = true - - -- Scroll state + -- Initialize scroll state self._textScrollX = 0 - -- Mouse tracking - self._mouseDownPosition = nil + -- Store callbacks + self.onFocus = config.onFocus + self.onBlur = config.onBlur + self.onTextInput = config.onTextInput + self.onTextChange = config.onTextChange + self.onEnter = config.onEnter -- Element reference (set via initialize) self._element = nil @@ -121,40 +111,31 @@ function TextEditor.new(config) return self end ---- Initialize with parent element reference ----@param element Element The parent element +---Initialize TextEditor with parent element reference +---@param element table The parent Element instance function TextEditor:initialize(element) self._element = element - -- Restore state from StateManager in immediate mode - if Gui._immediateMode and element._stateId then + -- Restore state from StateManager if in immediate mode + if element._stateId and GuiState._immediateMode then local state = StateManager.getState(element._stateId) if state then - -- Restore focus state if state._focused then self._focused = true - Gui._focusedElement = element + GuiState._focusedElement = element end - - -- Restore text buffer if state._textBuffer and state._textBuffer ~= "" then self._textBuffer = state._textBuffer end - - -- Restore cursor position if state._cursorPosition then self._cursorPosition = state._cursorPosition end - - -- Restore selection if state._selectionStart then self._selectionStart = state._selectionStart end if state._selectionEnd then self._selectionEnd = state._selectionEnd end - - -- Restore cursor blink state if state._cursorBlinkTimer then self._cursorBlinkTimer = state._cursorBlinkTimer end @@ -171,448 +152,29 @@ function TextEditor:initialize(element) end end --- ==================== --- Cursor Management --- ==================== - ---- Set cursor position ----@param position number Character index (0-based) -function TextEditor:setCursorPosition(position) - self._cursorPosition = position - self:_validateCursorPosition() - self:_resetCursorBlink() -end - ---- Get cursor position ----@return number Character index (0-based) -function TextEditor:getCursorPosition() - return self._cursorPosition -end - ---- Move cursor by delta characters ----@param delta number Number of characters to move (positive or negative) -function TextEditor:moveCursorBy(delta) - self._cursorPosition = self._cursorPosition + delta - self:_validateCursorPosition() - self:_resetCursorBlink() -end - ---- Move cursor to start of text -function TextEditor:moveCursorToStart() - self._cursorPosition = 0 - self:_resetCursorBlink() -end - ---- Move cursor to end of text -function TextEditor:moveCursorToEnd() - local textLength = utf8.len(self._textBuffer or "") - self._cursorPosition = textLength - self:_resetCursorBlink() -end - ---- Move cursor to start of current line -function TextEditor:moveCursorToLineStart() - -- For now, just move to start (will be enhanced for multi-line) - self:moveCursorToStart() -end - ---- Move cursor to end of current line -function TextEditor:moveCursorToLineEnd() - -- For now, just move to end (will be enhanced for multi-line) - self:moveCursorToEnd() -end - ---- Move cursor to start of previous word -function TextEditor:moveCursorToPreviousWord() - if not self._textBuffer then - return - end - - local text = self._textBuffer - local pos = self._cursorPosition - - if pos <= 0 then - return - end - - -- Helper function to get character at position - local function getCharAt(p) - if p < 0 or p >= utf8.len(text) then - return nil - end - local offset1 = utf8.offset(text, p + 1) - local offset2 = utf8.offset(text, p + 2) - if not offset1 then - return nil - end - if not offset2 then - return text:sub(offset1) - end - return text:sub(offset1, offset2 - 1) - end - - -- Skip any whitespace/punctuation before current position - while pos > 0 do - local char = getCharAt(pos - 1) - if char and char:match("[%w]") then - break - end - pos = pos - 1 - end - - -- Move to start of current word - while pos > 0 do - local char = getCharAt(pos - 1) - if not char or not char:match("[%w]") then - break - end - pos = pos - 1 - end - - self._cursorPosition = pos - self:_validateCursorPosition() -end - ---- Move cursor to start of next word -function TextEditor:moveCursorToNextWord() - if not self._textBuffer then - return - end - - local text = self._textBuffer - local textLength = utf8.len(text) or 0 - local pos = self._cursorPosition - - if pos >= textLength then - return - end - - -- Helper function to get character at position - local function getCharAt(p) - if p < 0 or p >= textLength then - return nil - end - local offset1 = utf8.offset(text, p + 1) - local offset2 = utf8.offset(text, p + 2) - if not offset1 then - return nil - end - if not offset2 then - return text:sub(offset1) - end - return text:sub(offset1, offset2 - 1) - end - - -- Skip current word - while pos < textLength do - local char = getCharAt(pos) - if not char or not char:match("[%w]") then - break - end - pos = pos + 1 - end - - -- Skip any whitespace/punctuation - while pos < textLength do - local char = getCharAt(pos) - if char and char:match("[%w]") then - break - end - pos = pos + 1 - end - - self._cursorPosition = pos - self:_validateCursorPosition() -end - ---- Validate cursor position (ensure it's within text bounds) -function TextEditor:_validateCursorPosition() - local textLength = utf8.len(self._textBuffer or "") or 0 - local cursorPos = tonumber(self._cursorPosition) or 0 - self._cursorPosition = math.max(0, math.min(cursorPos, textLength)) -end - ---- Reset cursor blink (show cursor immediately) ----@param pauseBlink boolean? Whether to pause blinking (for typing) -function TextEditor:_resetCursorBlink(pauseBlink) - self._cursorBlinkTimer = 0 - self._cursorVisible = true - - if pauseBlink then - self._cursorBlinkPaused = true - self._cursorBlinkPauseTimer = 0 - end - - -- Update scroll to keep cursor visible - self:_updateTextScroll() -end - ---- Update text scroll offset to keep cursor visible -function TextEditor:_updateTextScroll() - if not self._element or self.multiline then - return - end - - -- Get font for measuring text - local font = self:_getFont() - if not font then - return - end - - -- Calculate cursor X position in text coordinates - local cursorText = "" - if self._textBuffer and self._textBuffer ~= "" and self._cursorPosition > 0 then - local byteOffset = utf8.offset(self._textBuffer, self._cursorPosition + 1) - if byteOffset then - cursorText = self._textBuffer:sub(1, byteOffset - 1) - end - end - local cursorX = font:getWidth(cursorText) - - -- Get available text area width (accounting for padding) - local textAreaWidth = self._element.width - local scaledContentPadding = self._element:getScaledContentPadding() - if scaledContentPadding then - local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._element.padding.right) - textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right - end - - -- Add some padding on the right for the cursor - local cursorPadding = 4 - local visibleWidth = textAreaWidth - cursorPadding - - -- Adjust scroll to keep cursor visible - if cursorX - self._textScrollX < 0 then - -- Cursor is to the left of visible area - scroll left - self._textScrollX = cursorX - elseif cursorX - self._textScrollX > visibleWidth then - -- Cursor is to the right of visible area - scroll right - self._textScrollX = cursorX - visibleWidth - end - - -- Ensure we don't scroll past the beginning - self._textScrollX = math.max(0, self._textScrollX) -end - --- ==================== --- Selection Management --- ==================== - ---- Set selection range ----@param startPos number Start position (inclusive) ----@param endPos number End position (inclusive) -function TextEditor:setSelection(startPos, endPos) - local textLength = utf8.len(self._textBuffer or "") - self._selectionStart = math.max(0, math.min(startPos, textLength)) - self._selectionEnd = math.max(0, math.min(endPos, textLength)) - - -- Ensure start <= end - if self._selectionStart > self._selectionEnd then - self._selectionStart, self._selectionEnd = self._selectionEnd, self._selectionStart - end - - self:_resetCursorBlink() -end - ---- Get selection range ----@return number?, number? Start and end positions, or nil if no selection -function TextEditor:getSelection() - if not self:hasSelection() then - return nil, nil - end - return self._selectionStart, self._selectionEnd -end - ---- Check if there is an active selection ----@return boolean -function TextEditor:hasSelection() - return self._selectionStart ~= nil and self._selectionEnd ~= nil and self._selectionStart ~= self._selectionEnd -end - ---- Clear selection -function TextEditor:clearSelection() - self._selectionStart = nil - self._selectionEnd = nil - self._selectionAnchor = nil -end - ---- Select all text -function TextEditor:selectAll() - local textLength = utf8.len(self._textBuffer or "") - self._selectionStart = 0 - self._selectionEnd = textLength - self:_resetCursorBlink() -end - ---- Get selected text ----@return string? Selected text or nil if no selection -function TextEditor:getSelectedText() - if not self:hasSelection() then - return nil - end - local startPos, endPos = self:getSelection() - if not startPos or not endPos then - return nil - end - - -- Convert character indices to byte offsets for string.sub - local text = self._textBuffer or "" - local startByte = utf8.offset(text, startPos + 1) - local endByte = utf8.offset(text, endPos + 1) - - if not startByte then - return "" - end - - -- If endByte is nil, it means we want to the end of the string - if endByte then - endByte = endByte - 1 - end - - return string.sub(text, startByte, endByte) -end - ---- Delete selected text ----@return boolean True if text was deleted -function TextEditor:deleteSelection() - if not self:hasSelection() then - return false - end - local startPos, endPos = self:getSelection() - if not startPos or not endPos then - return false - end - - self:deleteText(startPos, endPos) - self:clearSelection() - self._cursorPosition = startPos - self:_validateCursorPosition() - - -- Save state to StateManager in immediate mode - self:_saveEditableState() - - return true -end - --- ==================== --- Focus Management --- ==================== - ---- Focus this element for keyboard input -function TextEditor:focus() - if not self._element then - return - end - - if Gui._focusedElement and Gui._focusedElement ~= self._element then - -- Blur the previously focused element - if Gui._focusedElement.editable and Gui._focusedElement._textEditor then - Gui._focusedElement._textEditor:blur() - else - Gui._focusedElement:blur() - end - end - - -- Set focus state - self._focused = true - Gui._focusedElement = self._element - - self:_resetCursorBlink() - - if self.selectOnFocus then - self:selectAll() - else - self:moveCursorToEnd() - end - - -- Trigger onFocus callback if defined - if self._element.onFocus then - self._element.onFocus(self._element) - end - - -- Save state to StateManager in immediate mode - self:_saveEditableState() -end - ---- Remove focus from this element -function TextEditor:blur() - if not self._element then - return - end - - self._focused = false - - -- Clear global focused element if it's this element - if Gui._focusedElement == self._element then - Gui._focusedElement = nil - end - - -- Trigger onBlur callback if defined - if self._element.onBlur then - self._element.onBlur(self._element) - end - - -- Save state to StateManager in immediate mode - self:_saveEditableState() -end - ---- Check if this element is focused ----@return boolean -function TextEditor:isFocused() - return self._focused == true -end - ---- Save editable element state to StateManager (for immediate mode) -function TextEditor:_saveEditableState() - if not self._element or not self._element._stateId or not Gui._immediateMode then - return - end - - StateManager.updateState(self._element._stateId, { - _focused = self._focused, - _textBuffer = self._textBuffer, - _cursorPosition = self._cursorPosition, - _selectionStart = self._selectionStart, - _selectionEnd = self._selectionEnd, - _cursorBlinkTimer = self._cursorBlinkTimer, - _cursorVisible = self._cursorVisible, - _cursorBlinkPaused = self._cursorBlinkPaused, - _cursorBlinkPauseTimer = self._cursorBlinkPauseTimer, - }) -end - -- ==================== -- Text Buffer Management -- ==================== ---- Get current text buffer +---Get current text buffer ---@return string function TextEditor:getText() return self._textBuffer or "" end ---- Set text buffer and mark dirty +---Set text buffer and mark dirty ---@param text string function TextEditor:setText(text) - if not self._element then - self._textBuffer = text or "" - return - end - self._textBuffer = text or "" - self._element.text = self._textBuffer -- Sync display text self:_markTextDirty() self:_updateTextIfDirty() - self:_updateAutoGrowHeight() self:_validateCursorPosition() - - -- Save state to StateManager in immediate mode - self:_saveEditableState() + self:_saveState() end ---- Insert text at position ----@param text string Text to insert ----@param position number? Position to insert at (default: cursor position) +---Insert text at position +---@param text string -- Text to insert +---@param position number? -- Position to insert at (default: cursor position) function TextEditor:insertText(text, position) position = position or self._cursorPosition local buffer = self._textBuffer or "" @@ -635,27 +197,19 @@ function TextEditor:insertText(text, position) local before = buffer:sub(1, byteOffset - 1) local after = buffer:sub(byteOffset) self._textBuffer = before .. text .. after - if self._element then - self._element.text = self._textBuffer - end self._cursorPosition = position + utf8.len(text) self:_markTextDirty() self:_updateTextIfDirty() - self:_updateAutoGrowHeight() self:_validateCursorPosition() - - -- Reset cursor blink to show cursor and pause blinking while typing self:_resetCursorBlink(true) - - -- Save state to StateManager in immediate mode - self:_saveEditableState() + self:_saveState() end ---- Delete text in range ----@param startPos number Start position (inclusive) ----@param endPos number End position (inclusive) +---Delete text in range +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) function TextEditor:deleteText(startPos, endPos) local buffer = self._textBuffer or "" @@ -676,36 +230,28 @@ function TextEditor:deleteText(startPos, endPos) local before = buffer:sub(1, startByte - 1) local after = buffer:sub(endByte) self._textBuffer = before .. after - if self._element then - self._element.text = self._textBuffer - end self:_markTextDirty() self:_updateTextIfDirty() - self:_updateAutoGrowHeight() - - -- Reset cursor blink to show cursor and pause blinking while deleting self:_resetCursorBlink(true) - - -- Save state to StateManager in immediate mode - self:_saveEditableState() + self:_saveState() end ---- Replace text in range ----@param startPos number Start position (inclusive) ----@param endPos number End position (inclusive) ----@param newText string Replacement text +---Replace text in range +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +---@param newText string -- Replacement text function TextEditor:replaceText(startPos, endPos, newText) self:deleteText(startPos, endPos) self:insertText(newText, startPos) end ---- Mark text as dirty (needs recalculation) +---Mark text as dirty (needs recalculation) function TextEditor:_markTextDirty() self._textDirty = true end ---- Update text if dirty (recalculate lines and wrapping) +---Update text if dirty (recalculate lines and wrapping) function TextEditor:_updateTextIfDirty() if not self._textDirty then return @@ -718,10 +264,10 @@ function TextEditor:_updateTextIfDirty() end -- ==================== --- Text Wrapping and Line Splitting +-- Line Splitting and Wrapping -- ==================== ---- Split text into lines (for multi-line text) +---Split text into lines (for multi-line text) function TextEditor:_splitLines() if not self.multiline then self._lines = { self._textBuffer or "" } @@ -742,9 +288,9 @@ function TextEditor:_splitLines() end end ---- Calculate text wrapping +---Calculate text wrapping function TextEditor:_calculateWrapping() - if not self._element or not self.textWrap then + if not self.textWrap or not self._element then self._wrappedLines = nil return end @@ -770,16 +316,20 @@ function TextEditor:_calculateWrapping() end end ---- Wrap a single line of text ----@param line string Line to wrap ----@param maxWidth number Maximum width in pixels ----@return table Array of wrapped line parts +---Wrap a single line of text +---@param line string -- Line to wrap +---@param maxWidth number -- Maximum width in pixels +---@return table -- Array of wrapped line parts function TextEditor:_wrapLine(line, maxWidth) if not self._element then return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } end local font = self:_getFont() + if not font then + return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } + end + local wrappedParts = {} local currentLine = "" local startIdx = 0 @@ -805,7 +355,6 @@ function TextEditor:_wrapLine(line, maxWidth) local lineLen = utf8.len(line) while pos <= lineLen do - -- Check if current position is whitespace local char = getUtf8Char(line, pos) if char:match("%s") then -- Collect whitespace sequence @@ -820,7 +369,7 @@ function TextEditor:_wrapLine(line, maxWidth) length = pos - wsStart, }) else - -- Collect word (non-whitespace sequence) + -- Collect word local wordStart = pos while pos <= lineLen and not getUtf8Char(line, pos):match("%s") do pos = pos + 1 @@ -853,7 +402,7 @@ function TextEditor:_wrapLine(line, maxWidth) currentLine = token.text charPos = charPos + token.length - -- Check if the word itself is too long - if so, break it with character wrapping + -- Check if the word itself is too long if font:getWidth(token.text) > maxWidth then local wordLen = utf8.len(token.text) local charLine = "" @@ -881,7 +430,7 @@ function TextEditor:_wrapLine(line, maxWidth) startIdx = charStartIdx end elseif width > maxWidth and currentLine == "" then - -- Word is too long to fit on a line by itself - use character wrapping + -- Word is too long to fit on a line by itself local wordLen = utf8.len(token.text) local charLine = "" local charStartIdx = startIdx @@ -960,27 +509,228 @@ function TextEditor:_wrapLine(line, maxWidth) return wrappedParts end ---- Get font for text rendering ----@return love.Font -function TextEditor:_getFont() - if not self._element then - return love.graphics.getFont() - end - - return self._element:_getFont() +-- ==================== +-- Cursor Management +-- ==================== + +---Set cursor position +---@param position number -- Character index (0-based) +function TextEditor:setCursorPosition(position) + self._cursorPosition = position + self:_validateCursorPosition() + self:_resetCursorBlink() end --- ==================== --- Cursor and Selection Screen Position --- ==================== +---Get cursor position +---@return number -- Character index (0-based) +function TextEditor:getCursorPosition() + return self._cursorPosition +end ---- Get cursor screen position for rendering (handles multiline text) ----@return number, number Cursor X and Y position relative to content area -function TextEditor:_getCursorScreenPosition() - if not self._element then - return 0, 0 +---Move cursor by delta characters +---@param delta number -- Number of characters to move (positive or negative) +function TextEditor:moveCursorBy(delta) + self._cursorPosition = self._cursorPosition + delta + self:_validateCursorPosition() + self:_resetCursorBlink() +end + +---Move cursor to start of text +function TextEditor:moveCursorToStart() + self._cursorPosition = 0 + self:_resetCursorBlink() +end + +---Move cursor to end of text +function TextEditor:moveCursorToEnd() + local textLength = utf8.len(self._textBuffer or "") + self._cursorPosition = textLength + self:_resetCursorBlink() +end + +---Move cursor to start of current line +function TextEditor:moveCursorToLineStart() + -- For now, just move to start (will be enhanced for multi-line) + self:moveCursorToStart() +end + +---Move cursor to end of current line +function TextEditor:moveCursorToLineEnd() + -- For now, just move to end (will be enhanced for multi-line) + self:moveCursorToEnd() +end + +---Move cursor to start of previous word +function TextEditor:moveCursorToPreviousWord() + if not self._textBuffer then + return end + local text = self._textBuffer + local pos = self._cursorPosition + + if pos <= 0 then + return + end + + -- Helper function to get character at position + local function getCharAt(p) + if p < 0 or p >= utf8.len(text) then + return nil + end + local offset1 = utf8.offset(text, p + 1) + local offset2 = utf8.offset(text, p + 2) + if not offset1 then + return nil + end + if not offset2 then + return text:sub(offset1) + end + return text:sub(offset1, offset2 - 1) + end + + -- Skip any whitespace/punctuation before current position + while pos > 0 do + local char = getCharAt(pos - 1) + if char and char:match("[%w]") then + break + end + pos = pos - 1 + end + + -- Move to start of current word + while pos > 0 do + local char = getCharAt(pos - 1) + if not char or not char:match("[%w]") then + break + end + pos = pos - 1 + end + + self._cursorPosition = pos + self:_validateCursorPosition() +end + +---Move cursor to start of next word +function TextEditor:moveCursorToNextWord() + if not self._textBuffer then + return + end + + local text = self._textBuffer + local textLength = utf8.len(text) or 0 + local pos = self._cursorPosition + + if pos >= textLength then + return + end + + -- Helper function to get character at position + local function getCharAt(p) + if p < 0 or p >= textLength then + return nil + end + local offset1 = utf8.offset(text, p + 1) + local offset2 = utf8.offset(text, p + 2) + if not offset1 then + return nil + end + if not offset2 then + return text:sub(offset1) + end + return text:sub(offset1, offset2 - 1) + end + + -- Skip current word + while pos < textLength do + local char = getCharAt(pos) + if not char or not char:match("[%w]") then + break + end + pos = pos + 1 + end + + -- Skip any whitespace/punctuation + while pos < textLength do + local char = getCharAt(pos) + if char and char:match("[%w]") then + break + end + pos = pos + 1 + end + + self._cursorPosition = pos + self:_validateCursorPosition() +end + +---Validate cursor position (ensure it's within text bounds) +function TextEditor:_validateCursorPosition() + local textLength = utf8.len(self._textBuffer or "") or 0 + local cursorPos = tonumber(self._cursorPosition) or 0 + self._cursorPosition = math.max(0, math.min(cursorPos, textLength)) +end + +---Reset cursor blink (show cursor immediately) +---@param pauseBlink boolean|nil -- Whether to pause blinking (for typing) +function TextEditor:_resetCursorBlink(pauseBlink) + self._cursorBlinkTimer = 0 + self._cursorVisible = true + + if pauseBlink then + self._cursorBlinkPaused = true + self._cursorBlinkPauseTimer = 0 + end + + self:_updateTextScroll() +end + +---Update text scroll offset to keep cursor visible +function TextEditor:_updateTextScroll() + if not self._element or self.multiline then + return + end + + local font = self:_getFont() + if not font then + return + end + + -- Calculate cursor X position in text coordinates + local cursorText = "" + if self._textBuffer and self._textBuffer ~= "" and self._cursorPosition > 0 then + local byteOffset = utf8.offset(self._textBuffer, self._cursorPosition + 1) + if byteOffset then + cursorText = self._textBuffer:sub(1, byteOffset - 1) + end + end + local cursorX = font:getWidth(cursorText) + + -- Get available text area width + local textAreaWidth = self._element.width + local scaledContentPadding = self._element:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._element.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Add some padding on the right for the cursor + local cursorPadding = 4 + local visibleWidth = textAreaWidth - cursorPadding + + -- Adjust scroll to keep cursor visible + if cursorX - self._textScrollX < 0 then + self._textScrollX = cursorX + elseif cursorX - self._textScrollX > visibleWidth then + self._textScrollX = cursorX - visibleWidth + end + + -- Ensure we don't scroll past the beginning + self._textScrollX = math.max(0, self._textScrollX) +end + +---Get cursor screen position for rendering (handles multiline text) +---@return number, number -- Cursor X and Y position relative to content area +function TextEditor:_getCursorScreenPosition() local font = self:_getFont() if not font then return 0, 0 @@ -1010,6 +760,10 @@ function TextEditor:_getCursorScreenPosition() -- For multiline text, we need to find which wrapped line the cursor is on self:_updateTextIfDirty() + if not self._element then + return 0, 0 + end + -- Get text area width for wrapping local textAreaWidth = self._element.width local scaledContentPadding = self._element:getScaledContentPadding() @@ -1036,9 +790,8 @@ function TextEditor:_getCursorScreenPosition() for lineNum, line in ipairs(lines) do local lineLength = utf8.len(line) or 0 - -- Check if cursor is on this line (before the newline) + -- Check if cursor is on this line if cursorPos <= charCount + lineLength then - -- Cursor is on this line local posInLine = cursorPos - charCount -- If text wrapping is enabled, find which wrapped segment @@ -1046,9 +799,7 @@ function TextEditor:_getCursorScreenPosition() local wrappedSegments = self:_wrapLine(line, textAreaWidth) for segmentIdx, segment in ipairs(wrappedSegments) do - -- Check if cursor is within this segment's character range if posInLine >= segment.startIdx and posInLine <= segment.endIdx then - -- Cursor is in this segment local posInSegment = posInLine - segment.startIdx local segmentText = "" if posInSegment > 0 and segment.text ~= "" then @@ -1089,24 +840,119 @@ function TextEditor:_getCursorScreenPosition() return 0, #lines * lineHeight end ---- Get selection rectangles for rendering (handles multiline and wrapped text) ----@param selStart number Selection start position (character index) ----@param selEnd number Selection end position (character index) ----@return table Array of rectangles {x, y, width, height} relative to content area -function TextEditor:_getSelectionRects(selStart, selEnd) - if not self._element then - return {} +-- ==================== +-- Selection Management +-- ==================== + +---Set selection range +---@param startPos number -- Start position (inclusive) +---@param endPos number -- End position (inclusive) +function TextEditor:setSelection(startPos, endPos) + local textLength = utf8.len(self._textBuffer or "") + self._selectionStart = math.max(0, math.min(startPos, textLength)) + self._selectionEnd = math.max(0, math.min(endPos, textLength)) + + -- Ensure start <= end + if self._selectionStart > self._selectionEnd then + self._selectionStart, self._selectionEnd = self._selectionEnd, self._selectionStart end + self:_resetCursorBlink() +end + +---Get selection range +---@return number?, number? -- Start and end positions, or nil if no selection +function TextEditor:getSelection() + if not self:hasSelection() then + return nil, nil + end + return self._selectionStart, self._selectionEnd +end + +---Check if there is an active selection +---@return boolean +function TextEditor:hasSelection() + return self._selectionStart ~= nil and self._selectionEnd ~= nil and self._selectionStart ~= self._selectionEnd +end + +---Clear selection +function TextEditor:clearSelection() + self._selectionStart = nil + self._selectionEnd = nil + self._selectionAnchor = nil +end + +---Select all text +function TextEditor:selectAll() + local textLength = utf8.len(self._textBuffer or "") + self._selectionStart = 0 + self._selectionEnd = textLength + self:_resetCursorBlink() +end + +---Get selected text +---@return string? -- Selected text or nil if no selection +function TextEditor:getSelectedText() + if not self:hasSelection() then + return nil + end + + local startPos, endPos = self:getSelection() + if not startPos or not endPos then + return nil + end + + -- Convert character indices to byte offsets + local text = self._textBuffer or "" + local startByte = utf8.offset(text, startPos + 1) + local endByte = utf8.offset(text, endPos + 1) + + if not startByte then + return "" + end + + if endByte then + endByte = endByte - 1 + end + + return string.sub(text, startByte, endByte) +end + +---Delete selected text +---@return boolean -- True if text was deleted +function TextEditor:deleteSelection() + if not self:hasSelection() then + return false + end + + local startPos, endPos = self:getSelection() + if not startPos or not endPos then + return false + end + + self:deleteText(startPos, endPos) + self:clearSelection() + self._cursorPosition = startPos + self:_validateCursorPosition() + self:_saveState() + + return true +end + +---Get selection rectangles for rendering +---@param selStart number -- Selection start position +---@param selEnd number -- Selection end position +---@return table -- Array of rectangles {x, y, width, height} +function TextEditor:_getSelectionRects(selStart, selEnd) local font = self:_getFont() - if not font then + if not font or not self._element then return {} end local text = self._textBuffer or "" local rects = {} - -- Apply password masking for selection rectangle calculation + -- Apply password masking local textForMeasurement = text if self.passwordMode and text ~= "" then textForMeasurement = string.rep("•", utf8.len(text)) @@ -1131,7 +977,7 @@ function TextEditor:_getSelectionRects(selStart, selEnd) return rects end - -- For multiline text, we need to handle line wrapping + -- For multiline text, handle line wrapping self:_updateTextIfDirty() -- Get text area width for wrapping @@ -1142,7 +988,7 @@ function TextEditor:_getSelectionRects(selStart, selEnd) textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right end - -- Split text by actual newlines first + -- Split text by actual newlines local lines = {} for line in (text .. "\n"):gmatch("([^\n]*)\n") do table.insert(lines, line) @@ -1157,28 +1003,21 @@ function TextEditor:_getSelectionRects(selStart, selEnd) for lineNum, line in ipairs(lines) do local lineLength = utf8.len(line) or 0 - - -- Check if selection intersects with this line local lineStartChar = charCount local lineEndChar = charCount + lineLength if selEnd > lineStartChar and selStart <= lineEndChar then - -- Selection intersects with this line local selStartInLine = math.max(0, selStart - charCount) local selEndInLine = math.min(lineLength, selEnd - charCount) - -- If text wrapping is enabled, handle wrapped segments if self.textWrap and textAreaWidth > 0 then local wrappedSegments = self:_wrapLine(line, textAreaWidth) for segmentIdx, segment in ipairs(wrappedSegments) do - -- Check if selection intersects with this segment if selEndInLine > segment.startIdx and selStartInLine <= segment.endIdx then - -- Selection intersects with this segment local segSelStart = math.max(segment.startIdx, selStartInLine) local segSelEnd = math.min(segment.endIdx, selEndInLine) - -- Calculate X position and width local beforeText = "" local selectedText = "" @@ -1206,7 +1045,7 @@ function TextEditor:_getSelectionRects(selStart, selEnd) visualLineNum = visualLineNum + 1 end else - -- No wrapping, simple calculation + -- No wrapping local beforeText = "" local selectedText = "" @@ -1232,7 +1071,7 @@ function TextEditor:_getSelectionRects(selStart, selEnd) visualLineNum = visualLineNum + 1 end else - -- Selection doesn't intersect, but we still need to count visual lines + -- Selection doesn't intersect, but count visual lines if self.textWrap and textAreaWidth > 0 then local wrappedSegments = self:_wrapLine(line, textAreaWidth) visualLineNum = visualLineNum + #wrappedSegments @@ -1248,363 +1087,102 @@ function TextEditor:_getSelectionRects(selStart, selEnd) end -- ==================== --- Auto-Grow Height +-- Focus Management -- ==================== ---- Update element height based on text content (for autoGrow multiline fields) -function TextEditor:_updateAutoGrowHeight() - if not self._element or not self.multiline or not self.autoGrow then - return - end - - local font = self:_getFont() - if not font then - return - end - - local text = self._textBuffer or "" - local lineHeight = font:getHeight() - - -- Get text area width for wrapping - local textAreaWidth = self._element.width - local scaledContentPadding = self._element:getScaledContentPadding() - if scaledContentPadding then - local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._element.padding.right) - textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right - end - - -- Split text by actual newlines - local lines = {} - for line in (text .. "\n"):gmatch("([^\n]*)\n") do - table.insert(lines, line) - end - if #lines == 0 then - lines = { "" } - end - - -- Count total wrapped lines - local totalWrappedLines = 0 - if self.textWrap and textAreaWidth > 0 then - for _, line in ipairs(lines) do - if line == "" then - totalWrappedLines = totalWrappedLines + 1 - else - local wrappedSegments = self:_wrapLine(line, textAreaWidth) - totalWrappedLines = totalWrappedLines + #wrappedSegments - end +---Focus this element for keyboard input +function TextEditor:focus() + if GuiState._focusedElement and GuiState._focusedElement ~= self._element then + -- Blur the previously focused element's text editor if it has one + if GuiState._focusedElement._textEditor then + GuiState._focusedElement._textEditor:blur() end + end + + self._focused = true + if self._element then + GuiState._focusedElement = self._element + end + + self:_resetCursorBlink() + + if self.selectOnFocus then + self:selectAll() else - totalWrappedLines = #lines + self:moveCursorToEnd() end - totalWrappedLines = math.max(1, totalWrappedLines) - - local newContentHeight = totalWrappedLines * lineHeight - - if self._element.height ~= newContentHeight then - self._element.height = newContentHeight - self._element._borderBoxHeight = self._element.height + self._element.padding.top + self._element.padding.bottom - if self._element.parent and not self._element._explicitlyAbsolute then - self._element.parent:layoutChildren() - end + if self.onFocus and self._element then + self.onFocus(self._element) end + + self:_saveState() +end + +---Remove focus from this element +function TextEditor:blur() + self._focused = false + + if self._element and GuiState._focusedElement == self._element then + GuiState._focusedElement = nil + end + + if self.onBlur and self._element then + self.onBlur(self._element) + end + + self:_saveState() +end + +---Check if this element is focused +---@return boolean +function TextEditor:isFocused() + return self._focused == true end -- ==================== --- Mouse Selection +-- Input Handling -- ==================== ---- Convert mouse coordinates to cursor position in text ----@param mouseX number Mouse X coordinate (absolute) ----@param mouseY number Mouse Y coordinate (absolute) ----@return number Cursor position (character index) -function TextEditor:_mouseToTextPosition(mouseX, mouseY) - if not self._element or not self._textBuffer then - return 0 - end - - -- Get content area bounds - local contentX = (self._element._absoluteX or self._element.x) + self._element.padding.left - local contentY = (self._element._absoluteY or self._element.y) + self._element.padding.top - - -- Calculate relative position within text area - local relativeX = mouseX - contentX - local relativeY = mouseY - contentY - - -- Get font for measuring text - local font = self:_getFont() - if not font then - return 0 - end - - local text = self._textBuffer - local textLength = utf8.len(text) or 0 - - -- === SINGLE-LINE TEXT HANDLING === - if not self.multiline then - -- Account for horizontal scroll offset in single-line inputs - if self._textScrollX then - relativeX = relativeX + self._textScrollX - end - - -- Find the character position closest to the click - local closestPos = 0 - local closestDist = math.huge - - -- Check each position in the text - for i = 0, textLength do - local offset = utf8.offset(text, i + 1) - local beforeText = offset and text:sub(1, offset - 1) or text - local textWidth = font:getWidth(beforeText) - - local dist = math.abs(relativeX - textWidth) - - if dist < closestDist then - closestDist = dist - closestPos = i - end - end - - return closestPos - end - - -- === MULTILINE TEXT HANDLING === - - -- Update text wrapping if dirty - self:_updateTextIfDirty() - - -- Split text into lines - local lines = {} - for line in (text .. "\n"):gmatch("([^\n]*)\n") do - table.insert(lines, line) - end - if #lines == 0 then - lines = { "" } - end - - local lineHeight = font:getHeight() - - -- Get text area width for wrapping calculations - local textAreaWidth = self._element.width - local scaledContentPadding = self._element:getScaledContentPadding() - if scaledContentPadding then - local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._element.padding.right) - textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right - end - - -- Determine which line the click is on based on Y coordinate - local clickedLineNum = math.floor(relativeY / lineHeight) + 1 - clickedLineNum = math.max(1, math.min(clickedLineNum, #lines)) - - -- Calculate character offset for lines before the clicked line - local charOffset = 0 - for i = 1, clickedLineNum - 1 do - local lineLen = utf8.len(lines[i]) or 0 - charOffset = charOffset + lineLen + 1 - end - - -- Get the clicked line - local clickedLine = lines[clickedLineNum] - local lineLen = utf8.len(clickedLine) or 0 - - -- If text wrapping is enabled, handle wrapped segments - if self.textWrap and textAreaWidth > 0 then - local wrappedSegments = self:_wrapLine(clickedLine, textAreaWidth) - - -- Determine which wrapped segment was clicked - local lineYOffset = (clickedLineNum - 1) * lineHeight - local segmentNum = math.floor((relativeY - lineYOffset) / lineHeight) + 1 - segmentNum = math.max(1, math.min(segmentNum, #wrappedSegments)) - - local segment = wrappedSegments[segmentNum] - - -- Find closest position within the segment - local segmentText = segment.text - local segmentLen = utf8.len(segmentText) or 0 - local closestPos = segment.startIdx - local closestDist = math.huge - - for i = 0, segmentLen do - local offset = utf8.offset(segmentText, i + 1) - local beforeText = offset and segmentText:sub(1, offset - 1) or segmentText - local textWidth = font:getWidth(beforeText) - local dist = math.abs(relativeX - textWidth) - - if dist < closestDist then - closestDist = dist - closestPos = segment.startIdx + i - end - end - - return charOffset + closestPos - end - - -- No wrapping - find closest position in the clicked line - local closestPos = 0 - local closestDist = math.huge - - for i = 0, lineLen do - local offset = utf8.offset(clickedLine, i + 1) - local beforeText = offset and clickedLine:sub(1, offset - 1) or clickedLine - local textWidth = font:getWidth(beforeText) - local dist = math.abs(relativeX - textWidth) - - if dist < closestDist then - closestDist = dist - closestPos = i - end - end - - return charOffset + closestPos -end - ---- Handle mouse click on text (set cursor position or start selection) ----@param mouseX number Mouse X coordinate ----@param mouseY number Mouse Y coordinate ----@param clickCount number Number of clicks (1=single, 2=double, 3=triple) -function TextEditor:handleTextClick(mouseX, mouseY, clickCount) +---Handle text input (character insertion) +---@param text string +function TextEditor:handleTextInput(text) if not self._focused then return end - if clickCount == 1 then - -- Single click: Set cursor position - local pos = self:_mouseToTextPosition(mouseX, mouseY) - self:setCursorPosition(pos) - self:clearSelection() - - -- Store position for potential drag selection - self._mouseDownPosition = pos - elseif clickCount == 2 then - -- Double click: Select word - self:_selectWordAtPosition(self:_mouseToTextPosition(mouseX, mouseY)) - elseif clickCount >= 3 then - -- Triple click: Select all - self:selectAll() - end - - self:_resetCursorBlink() -end - ---- Handle mouse drag for text selection ----@param mouseX number Mouse X coordinate ----@param mouseY number Mouse Y coordinate -function TextEditor:handleTextDrag(mouseX, mouseY) - if not self._focused or not self._mouseDownPosition then - return - end - - local currentPos = self:_mouseToTextPosition(mouseX, mouseY) - - -- Create selection from mouse down position to current position - if currentPos ~= self._mouseDownPosition then - self:setSelection(self._mouseDownPosition, currentPos) - self._cursorPosition = currentPos - else - self:clearSelection() - end - - self:_resetCursorBlink() -end - ---- Select word at given position ----@param position number Character position -function TextEditor:_selectWordAtPosition(position) - if not self._textBuffer then - return - end - - local text = self._textBuffer - local textLength = utf8.len(text) or 0 - - if position < 0 or position > textLength then - return - end - - -- Find word boundaries - local wordStart = position - local wordEnd = position - - -- Find start of word (move left while alphanumeric) - while wordStart > 0 do - local offset = utf8.offset(text, wordStart) - local char = offset and text:sub(offset, utf8.offset(text, wordStart + 1) - 1) or "" - if char:match("[%w]") then - wordStart = wordStart - 1 - else - break - end - end - - -- Find end of word (move right while alphanumeric) - while wordEnd < textLength do - local offset = utf8.offset(text, wordEnd + 1) - local char = offset and text:sub(offset, utf8.offset(text, wordEnd + 2) - 1) or "" - if char:match("[%w]") then - wordEnd = wordEnd + 1 - else - break - end - end - - -- Select the word - if wordEnd > wordStart then - self:setSelection(wordStart, wordEnd) - self._cursorPosition = wordEnd - end -end - ---- Clear mouse down position (called on mouse release) -function TextEditor:clearMouseDownPosition() - self._mouseDownPosition = nil -end - --- ==================== --- Keyboard Input --- ==================== - ---- Handle text input (character input) ----@param text string Character(s) to insert -function TextEditor:handleInput(text) - if not self._focused or not self._element then - return - end - -- Trigger onTextInput callback if defined - if self._element.onTextInput then - local result = self._element.onTextInput(self._element, text) + if self.onTextInput and self._element then + local result = self.onTextInput(self._element, text) if result == false then return end end - -- Capture old text for callback local oldText = self._textBuffer -- Delete selection if exists - local hadSelection = self:hasSelection() - if hadSelection then + if self:hasSelection() then self:deleteSelection() end -- Insert text at cursor position self:insertText(text) - -- Trigger onTextChange callback if text changed - if self._element.onTextChange and self._textBuffer ~= oldText then - self._element.onTextChange(self._element, self._textBuffer, oldText) + -- Trigger onTextChange callback + if self.onTextChange and self._textBuffer ~= oldText and self._element then + self.onTextChange(self._element, self._textBuffer, oldText) end - -- Save state to StateManager in immediate mode - self:_saveEditableState() + self:_saveState() end ---- Handle key press (special keys) ----@param key string Key name ----@param scancode string Scancode ----@param isrepeat boolean Whether this is a key repeat +---Handle key press (special keys) +---@param key string -- Key name +---@param scancode string -- Scancode +---@param isrepeat boolean -- Whether this is a key repeat function TextEditor:handleKeyPress(key, scancode, isrepeat) - if not self._focused or not self._element then + if not self._focused then return end @@ -1613,15 +1191,10 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) -- Handle cursor movement with selection if key == "left" or key == "right" or key == "home" or key == "end" or key == "up" or key == "down" then - -- Set selection anchor if Shift is pressed and no anchor exists if modifiers.shift and not self._selectionAnchor then self._selectionAnchor = self._cursorPosition end - -- Store old cursor position - local oldCursorPos = self._cursorPosition - - -- Move cursor based on key if key == "left" then if modifiers.super then self:moveCursorToStart() @@ -1670,13 +1243,7 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) if not modifiers.shift then self:clearSelection() end - elseif key == "up" then - -- TODO: Implement up/down for multi-line - if not modifiers.shift then - self:clearSelection() - end - elseif key == "down" then - -- TODO: Implement up/down for multi-line + elseif key == "up" or key == "down" then if not modifiers.shift then self:clearSelection() end @@ -1697,7 +1264,6 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) if self:hasSelection() then self:deleteSelection() elseif ctrl then - -- Ctrl/Cmd+Backspace: Delete all text from start to cursor if self._cursorPosition > 0 then self:deleteText(0, self._cursorPosition) self._cursorPosition = 0 @@ -1711,8 +1277,8 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) self:_validateCursorPosition() end - if self._element.onTextChange and self._textBuffer ~= oldText then - self._element.onTextChange(self._element, self._textBuffer, oldText) + if self.onTextChange and self._textBuffer ~= oldText and self._element then + self.onTextChange(self._element, self._textBuffer, oldText) end self:_resetCursorBlink(true) @@ -1727,8 +1293,8 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) end end - if self._element.onTextChange and self._textBuffer ~= oldText then - self._element.onTextChange(self._element, self._textBuffer, oldText) + if self.onTextChange and self._textBuffer ~= oldText and self._element then + self.onTextChange(self._element, self._textBuffer, oldText) end self:_resetCursorBlink(true) @@ -1741,12 +1307,12 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) end self:insertText("\n") - if self._element.onTextChange and self._textBuffer ~= oldText then - self._element.onTextChange(self._element, self._textBuffer, oldText) + if self.onTextChange and self._textBuffer ~= oldText and self._element then + self.onTextChange(self._element, self._textBuffer, oldText) end else - if self._element.onEnter then - self._element.onEnter(self._element) + if self.onEnter and self._element then + self.onEnter(self._element) end end self:_resetCursorBlink(true) @@ -1776,8 +1342,8 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) local oldText = self._textBuffer self:deleteSelection() - if self._element.onTextChange and self._textBuffer ~= oldText then - self._element.onTextChange(self._element, self._textBuffer, oldText) + if self.onTextChange and self._textBuffer ~= oldText and self._element then + self.onTextChange(self._element, self._textBuffer, oldText) end end end @@ -1795,8 +1361,8 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) self:insertText(clipboardText) - if self._element.onTextChange and self._textBuffer ~= oldText then - self._element.onTextChange(self._element, self._textBuffer, oldText) + if self.onTextChange and self._textBuffer ~= oldText and self._element then + self.onTextChange(self._element, self._textBuffer, oldText) end end self:_resetCursorBlink(true) @@ -1811,31 +1377,264 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) self:_resetCursorBlink() end - -- Save state to StateManager in immediate mode - self:_saveEditableState() + self:_saveState() end -- ==================== --- Update +-- Mouse Input -- ==================== ---- Update cursor blink timer ----@param dt number Delta time +---Convert mouse coordinates to cursor position in text +---@param mouseX number -- Mouse X coordinate (absolute) +---@param mouseY number -- Mouse Y coordinate (absolute) +---@return number -- Cursor position (character index) +function TextEditor:mouseToTextPosition(mouseX, mouseY) + if not self._element or not self._textBuffer then + return 0 + end + + local font = self:_getFont() + if not font then + return 0 + end + + -- Get content area bounds + local contentX = (self._element._absoluteX or self._element.x) + self._element.padding.left + local contentY = (self._element._absoluteY or self._element.y) + self._element.padding.top + + -- Calculate relative position + local relativeX = mouseX - contentX + local relativeY = mouseY - contentY + + local text = self._textBuffer + local textLength = utf8.len(text) or 0 + + -- Single-line handling + if not self.multiline then + if self._textScrollX then + relativeX = relativeX + self._textScrollX + end + + local closestPos = 0 + local closestDist = math.huge + + for i = 0, textLength do + local offset = utf8.offset(text, i + 1) + local beforeText = offset and text:sub(1, offset - 1) or text + local textWidth = font:getWidth(beforeText) + local dist = math.abs(relativeX - textWidth) + + if dist < closestDist then + closestDist = dist + closestPos = i + end + end + + return closestPos + end + + -- Multiline handling + self:_updateTextIfDirty() + + -- Split text into lines + local lines = {} + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + if #lines == 0 then + lines = { "" } + end + + local lineHeight = font:getHeight() + + -- Get text area width + local textAreaWidth = self._element.width + local scaledContentPadding = self._element:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._element.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right + end + + -- Determine which line was clicked + local clickedLineNum = math.floor(relativeY / lineHeight) + 1 + clickedLineNum = math.max(1, math.min(clickedLineNum, #lines)) + + -- Calculate character offset for lines before clicked line + local charOffset = 0 + for i = 1, clickedLineNum - 1 do + local lineLen = utf8.len(lines[i]) or 0 + charOffset = charOffset + lineLen + 1 + end + + local clickedLine = lines[clickedLineNum] + local lineLen = utf8.len(clickedLine) or 0 + + -- Handle wrapped segments + if self.textWrap and textAreaWidth > 0 then + local wrappedSegments = self:_wrapLine(clickedLine, textAreaWidth) + local lineYOffset = (clickedLineNum - 1) * lineHeight + local segmentNum = math.floor((relativeY - lineYOffset) / lineHeight) + 1 + segmentNum = math.max(1, math.min(segmentNum, #wrappedSegments)) + + local segment = wrappedSegments[segmentNum] + local segmentText = segment.text + local segmentLen = utf8.len(segmentText) or 0 + local closestPos = segment.startIdx + local closestDist = math.huge + + for i = 0, segmentLen do + local offset = utf8.offset(segmentText, i + 1) + local beforeText = offset and segmentText:sub(1, offset - 1) or segmentText + local textWidth = font:getWidth(beforeText) + local dist = math.abs(relativeX - textWidth) + + if dist < closestDist then + closestDist = dist + closestPos = segment.startIdx + i + end + end + + return charOffset + closestPos + end + + -- No wrapping + local closestPos = 0 + local closestDist = math.huge + + for i = 0, lineLen do + local offset = utf8.offset(clickedLine, i + 1) + local beforeText = offset and clickedLine:sub(1, offset - 1) or clickedLine + local textWidth = font:getWidth(beforeText) + local dist = math.abs(relativeX - textWidth) + + if dist < closestDist then + closestDist = dist + closestPos = i + end + end + + return charOffset + closestPos +end + +---Handle mouse click on text +---@param mouseX number +---@param mouseY number +---@param clickCount number -- 1=single, 2=double, 3=triple +function TextEditor:handleTextClick(mouseX, mouseY, clickCount) + if not self._focused then + return + end + + if clickCount == 1 then + local pos = self:mouseToTextPosition(mouseX, mouseY) + self:setCursorPosition(pos) + self:clearSelection() + self._mouseDownPosition = pos + elseif clickCount == 2 then + self:_selectWordAtPosition(self:mouseToTextPosition(mouseX, mouseY)) + elseif clickCount >= 3 then + self:selectAll() + end + + self:_resetCursorBlink() +end + +---Handle mouse drag for text selection +---@param mouseX number +---@param mouseY number +function TextEditor:handleTextDrag(mouseX, mouseY) + if not self._focused or not self._mouseDownPosition then + return + end + + local currentPos = self:mouseToTextPosition(mouseX, mouseY) + + if currentPos ~= self._mouseDownPosition then + self:setSelection(self._mouseDownPosition, currentPos) + self._cursorPosition = currentPos + self._textDragOccurred = true + else + self:clearSelection() + end + + self:_resetCursorBlink() +end + +---Select word at given position +---@param position number +function TextEditor:_selectWordAtPosition(position) + if not self._textBuffer then + return + end + + local text = self._textBuffer + local textLength = utf8.len(text) or 0 + + if textLength == 0 then + return + end + + -- Helper to get character at position + local function getCharAt(p) + if p < 0 or p >= textLength then + return nil + end + local offset1 = utf8.offset(text, p + 1) + local offset2 = utf8.offset(text, p + 2) + if not offset1 then + return nil + end + if not offset2 then + return text:sub(offset1) + end + return text:sub(offset1, offset2 - 1) + end + + -- Find word boundaries + local startPos = position + local endPos = position + + -- Expand left to start of word + while startPos > 0 do + local char = getCharAt(startPos - 1) + if not char or not char:match("[%w]") then + break + end + startPos = startPos - 1 + end + + -- Expand right to end of word + while endPos < textLength do + local char = getCharAt(endPos) + if not char or not char:match("[%w]") then + break + end + endPos = endPos + 1 + end + + self:setSelection(startPos, endPos) + self._cursorPosition = endPos +end + +-- ==================== +-- Update and Rendering +-- ==================== + +---Update cursor blink animation +---@param dt number -- Delta time function TextEditor:update(dt) if not self._focused then return end - -- If blink is paused, increment pause timer + -- Update cursor blink if self._cursorBlinkPaused then self._cursorBlinkPauseTimer = (self._cursorBlinkPauseTimer or 0) + dt - -- Unpause after 0.5 seconds of no typing if self._cursorBlinkPauseTimer >= 0.5 then self._cursorBlinkPaused = false self._cursorBlinkPauseTimer = 0 end else - -- Normal blinking self._cursorBlinkTimer = self._cursorBlinkTimer + dt if self._cursorBlinkTimer >= self.cursorBlinkRate then self._cursorBlinkTimer = 0 @@ -1844,17 +1643,9 @@ function TextEditor:update(dt) end end --- ==================== --- Draw --- ==================== - ---- Draw text, cursor, and selection ----@param x number Content area X position ----@param y number Content area Y position ----@param width number Content area width ----@param height number Content area height -function TextEditor:draw(x, y, width, height) - if not self._element then +---Update element height based on text content (for autoGrow) +function TextEditor:updateAutoGrowHeight() + if not self.multiline or not self.autoGrow or not self._element then return end @@ -1863,63 +1654,96 @@ function TextEditor:draw(x, y, width, height) return end - local textHeight = font:getHeight() + local text = self._textBuffer or "" + local lineHeight = font:getHeight() - -- Draw selection highlight - if self._focused and self:hasSelection() and self._textBuffer and self._textBuffer ~= "" then - local selStart, selEnd = self: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._element.opacity) - - local selectionRects = self:_getSelectionRects(selStart, selEnd) - - -- Apply scissor for single-line editable inputs - if not self.multiline then - love.graphics.setScissor(x, y, width, height) - end - - love.graphics.setColor(selectionWithOpacity:toRGBA()) - for _, rect in ipairs(selectionRects) do - local rectX = x + rect.x - local rectY = y + rect.y - if not self.multiline and self._textScrollX then - rectX = rectX - self._textScrollX - end - love.graphics.rectangle("fill", rectX, rectY, rect.width, rect.height) - end - - if not self.multiline then - love.graphics.setScissor() - end + -- Get text area width + local textAreaWidth = self._element.width + local scaledContentPadding = self._element:getScaledContentPadding() + if scaledContentPadding then + local borderBoxWidth = self._element._borderBoxWidth or (self._element.width + self._element.padding.left + self._element.padding.right) + textAreaWidth = borderBoxWidth - scaledContentPadding.left - scaledContentPadding.right end - -- Draw cursor - if self._focused and self._cursorVisible then - local cursorColor = self.cursorColor or self._element.textColor - local cursorWithOpacity = Color.new(cursorColor.r, cursorColor.g, cursorColor.b, cursorColor.a * self._element.opacity) - love.graphics.setColor(cursorWithOpacity:toRGBA()) - - local cursorRelX, cursorRelY = self:_getCursorScreenPosition() - local cursorX = x + cursorRelX - local cursorY = y + cursorRelY - local cursorHeight = textHeight - - -- Apply scroll offset for single-line inputs - if not self.multiline and self._textScrollX then - cursorX = cursorX - self._textScrollX + -- Split text by newlines + local lines = {} + for line in (text .. "\n"):gmatch("([^\n]*)\n") do + table.insert(lines, line) + end + if #lines == 0 then + lines = { "" } + end + + -- Count total wrapped lines + local totalWrappedLines = 0 + if self.textWrap and textAreaWidth > 0 then + for _, line in ipairs(lines) do + if line == "" then + totalWrappedLines = totalWrappedLines + 1 + else + local wrappedSegments = self:_wrapLine(line, textAreaWidth) + totalWrappedLines = totalWrappedLines + #wrappedSegments + end end - - -- Apply scissor for single-line editable inputs - if not self.multiline then - love.graphics.setScissor(x, y, width, height) - end - - love.graphics.rectangle("fill", cursorX, cursorY, 2, cursorHeight) - - if not self.multiline then - love.graphics.setScissor() + else + totalWrappedLines = #lines + end + + totalWrappedLines = math.max(1, totalWrappedLines) + local newContentHeight = totalWrappedLines * lineHeight + + if self._element.height ~= newContentHeight then + self._element.height = newContentHeight + self._element._borderBoxHeight = self._element.height + self._element.padding.top + self._element.padding.bottom + if self._element.parent and not self._element._explicitlyAbsolute then + self._element.parent:layoutChildren() end end end +-- ==================== +-- Helper Methods +-- ==================== + +---Get font for text rendering +---@return love.Font? +function TextEditor:_getFont() + if not self._element then + return nil + end + + -- Resolve font path + local fontPath = nil + if self._element.fontFamily then + local Theme = req("Theme") + local themeToUse = self._element.theme and Theme.get(self._element.theme) or Theme.getActive() + if themeToUse and themeToUse.fonts and themeToUse.fonts[self._element.fontFamily] then + fontPath = themeToUse.fonts[self._element.fontFamily] + else + fontPath = self._element.fontFamily + end + end + + return FONT_CACHE.getFont(self._element.textSize, fontPath) +end + +---Save state to StateManager (for immediate mode) +function TextEditor:_saveState() + if not self._element or not self._element._stateId or not GuiState._immediateMode then + return + end + + StateManager.updateState(self._element._stateId, { + _focused = self._focused, + _textBuffer = self._textBuffer, + _cursorPosition = self._cursorPosition, + _selectionStart = self._selectionStart, + _selectionEnd = self._selectionEnd, + _cursorBlinkTimer = self._cursorBlinkTimer, + _cursorVisible = self._cursorVisible, + _cursorBlinkPaused = self._cursorBlinkPaused, + _cursorBlinkPauseTimer = self._cursorBlinkPauseTimer, + }) +end + return TextEditor diff --git a/modules/ThemeManager.lua b/modules/ThemeManager.lua deleted file mode 100644 index 6a7f59e..0000000 --- a/modules/ThemeManager.lua +++ /dev/null @@ -1,436 +0,0 @@ ---[[ -ThemeManager - Theme and State Management for FlexLove Elements -Extracts all theme-related functionality from Element.lua into a dedicated module. -Handles theme state management, component loading, 9-patch rendering, and property resolution. -]] - --- Setup module path for relative requires -local modulePath = (...):match("(.-)[^%.]+$") -local function req(name) - return require(modulePath .. name) -end - --- Module dependencies -local Theme = req("Theme") -local NinePatch = req("NinePatch") -local StateManager = req("StateManager") - ---- Standardized error message formatter ----@param module string -- Module name ----@param message string -- Error message ----@return string -- Formatted error message -local function formatError(module, message) - return string.format("[FlexLove.%s] %s", module, message) -end - ----@class ThemeManager ----@field theme string? -- Theme name to use ----@field themeComponent string? -- Component name from theme ----@field _themeState string -- Current theme state (normal, hover, pressed, active, disabled) ----@field disabled boolean -- Whether element is disabled ----@field active boolean -- Whether element is active/focused ----@field disableHighlight boolean -- Whether to disable pressed state highlight ----@field scaleCorners number? -- Scale multiplier for 9-patch corners ----@field scalingAlgorithm "nearest"|"bilinear"? -- Scaling algorithm for 9-patch ----@field contentAutoSizingMultiplier table? -- Multiplier for auto-sized content ----@field _element Element? -- Reference to parent element (set via initialize) ----@field _stateId string? -- State manager ID for immediate mode -local ThemeManager = {} -ThemeManager.__index = ThemeManager - ---- Create a new ThemeManager instance ----@param config table -- Configuration options ----@return ThemeManager -function ThemeManager.new(config) - local self = setmetatable({}, ThemeManager) - - -- Theme configuration - self.theme = config.theme - self.themeComponent = config.themeComponent - - -- State properties - self._themeState = "normal" - self.disabled = config.disabled or false - self.active = config.active or false - self.disableHighlight = config.disableHighlight - - -- 9-patch rendering properties - self.scaleCorners = config.scaleCorners - self.scalingAlgorithm = config.scalingAlgorithm - - -- Content sizing properties - self.contentAutoSizingMultiplier = config.contentAutoSizingMultiplier - - -- Element reference (set via initialize) - self._element = nil - self._stateId = config.stateId - - return self -end - ---- Initialize ThemeManager with parent element reference ---- This links the ThemeManager to its parent element for accessing dimensions and state ----@param element Element -- Parent element -function ThemeManager:initialize(element) - self._element = element - self._stateId = element._stateId or element.id -end - ---- Update theme state based on interaction ---- State priority: disabled > pressed > active > hover > normal ----@param isHovered boolean -- Whether element is hovered ----@param isPressed boolean -- Whether element is pressed (any button) ----@param isFocused boolean -- Whether element is focused ----@param isDisabled boolean -- Whether element is disabled -function ThemeManager:updateState(isHovered, isPressed, isFocused, isDisabled) - if not self.themeComponent then - return - end - - local newThemeState = "normal" - - -- State priority: disabled > active > pressed > hover > normal - if isDisabled or self.disabled then - newThemeState = "disabled" - elseif self.active or isFocused then - newThemeState = "active" - elseif isPressed then - newThemeState = "pressed" - elseif isHovered then - newThemeState = "hover" - end - - -- Update local state - self._themeState = newThemeState - - -- Update StateManager if in immediate mode - if self._stateId then - local GuiState = req("GuiState") - if GuiState._immediateMode then - StateManager.updateState(self._stateId, { - hover = (newThemeState == "hover"), - pressed = (newThemeState == "pressed"), - focused = (newThemeState == "active" or isFocused), - disabled = isDisabled or self.disabled, - active = self.active, - }) - end - end -end - ---- Get current theme state ----@return string -- Current state (normal, hover, pressed, active, disabled) -function ThemeManager:getState() - return self._themeState -end - ---- Get theme component for current state ---- Returns the component data with state-specific overrides applied ----@return table|nil -- Component data or nil if not found -function ThemeManager:getThemeComponent() - if not self.themeComponent then - return nil - end - - -- Get the theme to use - local themeToUse = self:_getTheme() - if not themeToUse then - return nil - end - - -- Get the component from the theme - local component = themeToUse.components[self.themeComponent] - if not component then - return nil - end - - -- Check for state-specific override - local state = self._themeState - if state and state ~= "normal" and component.states and component.states[state] then - component = component.states[state] - end - - return component -end - ---- Check if theme component exists ----@return boolean -function ThemeManager:hasThemeComponent() - return self.themeComponent ~= nil and self:getThemeComponent() ~= nil -end - ---- Get the theme to use (element theme or active theme) ----@return table|nil -- Theme data or nil if not found -function ThemeManager:_getTheme() - 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 - - return themeToUse -end - ---- Get atlas image for current component ----@return love.Image|nil -- Atlas image or nil -function ThemeManager:_getAtlas() - local component = self:getThemeComponent() - if not component then - return nil - end - - local themeToUse = self:_getTheme() - if not themeToUse then - return nil - end - - -- Use component-specific atlas if available, otherwise use theme atlas - return component._loadedAtlas or themeToUse.atlas -end - ---- Render theme component (9-patch or other) ----@param x number -- X position ----@param y number -- Y position ----@param width number -- Width (border-box) ----@param height number -- Height (border-box) ----@param opacity number? -- Opacity (0-1) -function ThemeManager:render(x, y, width, height, opacity) - if not self.themeComponent then - return - end - - opacity = opacity or 1 - - -- Get the theme to use - local themeToUse = self:_getTheme() - 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 for 9-patch - 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 - -- Render 9-patch with element-level overrides - NinePatch.draw( - component, - atlasToUse, - x, - y, - width, - height, - opacity, - self.scaleCorners, - self.scalingAlgorithm - ) - else - -- Silently skip drawing if component structure is invalid - end - end -end - ---- Get styled property value from theme for current state ---- This allows theme components to provide default values for properties ----@param property string -- Property name (e.g., "backgroundColor", "textColor") ----@return any|nil -- Property value or nil if not found -function ThemeManager:getStyle(property) - local component = self:getThemeComponent() - if not component then - return nil - end - - -- Check if component has style properties - if component.style and component.style[property] then - return component.style[property] - end - - return nil -end - ---- Set theme and component ----@param themeName string? -- Theme name ----@param componentName string? -- Component name -function ThemeManager:setTheme(themeName, componentName) - self.theme = themeName - self.themeComponent = componentName -end - ---- Get scale corners multiplier ----@return number|nil -function ThemeManager:getScaleCorners() - -- Element-level override takes priority - if self.scaleCorners ~= nil then - return self.scaleCorners - end - - -- Fall back to component setting - local component = self:getThemeComponent() - if component and component.scaleCorners then - return component.scaleCorners - end - - return nil -end - ---- Get scaling algorithm ----@return "nearest"|"bilinear" -function ThemeManager:getScalingAlgorithm() - -- Element-level override takes priority - if self.scalingAlgorithm ~= nil then - return self.scalingAlgorithm - end - - -- Fall back to component setting - local component = self:getThemeComponent() - if component and component.scalingAlgorithm then - return component.scalingAlgorithm - end - - -- Default to bilinear - return "bilinear" -end - ---- Get the current state's scaled content padding ---- Returns the contentPadding for the current theme state, scaled to the element's size ----@param borderBoxWidth number -- Border-box width ----@param borderBoxHeight number -- Border-box height ----@return table|nil -- {left, top, right, bottom} or nil if no contentPadding -function ThemeManager:getScaledContentPadding(borderBoxWidth, borderBoxHeight) - if not self.themeComponent then - return nil - end - - local themeToUse = self:_getTheme() - if not themeToUse or not themeToUse.components[self.themeComponent] then - return nil - end - - local component = themeToUse.components[self.themeComponent] - - -- Check for state-specific override - local state = self._themeState or "normal" - if state and state ~= "normal" and component.states and component.states[state] then - component = component.states[state] - end - - if not component._ninePatchData or not component._ninePatchData.contentPadding then - return nil - end - - local contentPadding = component._ninePatchData.contentPadding - - -- Scale contentPadding to match the actual rendered size - local atlasImage = component._loadedAtlas or themeToUse.atlas - if atlasImage and type(atlasImage) ~= "string" then - local originalWidth, originalHeight = atlasImage:getDimensions() - local scaleX = borderBoxWidth / originalWidth - local scaleY = borderBoxHeight / originalHeight - - return { - left = contentPadding.left * scaleX, - top = contentPadding.top * scaleY, - right = contentPadding.right * scaleX, - bottom = contentPadding.bottom * scaleY, - } - else - -- Return unscaled values as fallback - return { - left = contentPadding.left, - top = contentPadding.top, - right = contentPadding.right, - bottom = contentPadding.bottom, - } - end -end - ---- Get content auto-sizing multiplier from theme ---- Priority: element config > theme component > theme default ----@return table -- {width, height} multipliers -function ThemeManager:getContentAutoSizingMultiplier() - -- If explicitly set in config, use that - if self.contentAutoSizingMultiplier then - return self.contentAutoSizingMultiplier - end - - -- Try to source from theme - local themeToUse = self:_getTheme() - if themeToUse then - -- First check if themeComponent has a multiplier - if self.themeComponent then - local component = themeToUse.components[self.themeComponent] - if component and component.contentAutoSizingMultiplier then - return component.contentAutoSizingMultiplier - elseif themeToUse.contentAutoSizingMultiplier then - -- Fall back to theme default - return themeToUse.contentAutoSizingMultiplier - end - elseif themeToUse.contentAutoSizingMultiplier then - return themeToUse.contentAutoSizingMultiplier - end - end - - -- Default multiplier - return { 1, 1 } -end - ---- Update disabled state ----@param disabled boolean -function ThemeManager:setDisabled(disabled) - self.disabled = disabled -end - ---- Update active state ----@param active boolean -function ThemeManager:setActive(active) - self.active = active -end - ---- Get disabled state ----@return boolean -function ThemeManager:isDisabled() - return self.disabled -end - ---- Get active state ----@return boolean -function ThemeManager:isActive() - return self.active -end - -return ThemeManager