diff --git a/modules/Element.lua b/modules/Element.lua index 956c038..1bdafd0 100644 --- a/modules/Element.lua +++ b/modules/Element.lua @@ -12,7 +12,6 @@ local Blur = req("Blur") local ImageRenderer = req("ImageRenderer") local NinePatch = req("NinePatch") local RoundedRect = req("RoundedRect") ---local Animation = req("Animation") local ImageCache = req("ImageCache") local utils = req("utils") local Grid = req("Grid") @@ -1149,6 +1148,8 @@ function Element.new(props) }, { utils = utils, Grid = Grid, + Units = Units, + Gui = Gui, }) -- Initialize immediately so it can be used for auto-sizing calculations self._layoutEngine:initialize(self) diff --git a/modules/EventHandler.lua b/modules/EventHandler.lua index 8b1cb4f..07f2563 100644 --- a/modules/EventHandler.lua +++ b/modules/EventHandler.lua @@ -32,11 +32,6 @@ EventHandler.__index = EventHandler ---@param deps table Dependencies {InputEvent, GuiState} ---@return EventHandler function EventHandler.new(config, deps) - -- Pure DI: Dependencies must be injected - assert(deps, "EventHandler.new: deps parameter is required") - assert(deps.InputEvent, "EventHandler.new: deps.InputEvent is required") - assert(deps.GuiState, "EventHandler.new: deps.GuiState is required") - config = config or {} local self = setmetatable({}, EventHandler) diff --git a/modules/LayoutEngine.lua b/modules/LayoutEngine.lua index 4b3d5ce..b666448 100644 --- a/modules/LayoutEngine.lua +++ b/modules/LayoutEngine.lua @@ -42,15 +42,9 @@ LayoutEngine.__index = LayoutEngine --- Create a new LayoutEngine instance ---@param props LayoutEngineProps ----@param deps table Dependencies {utils, Grid} +---@param deps table Dependencies {utils, Grid, Units, Gui} ---@return LayoutEngine function LayoutEngine.new(props, deps) - -- Pure DI: Dependencies must be injected - assert(deps, "LayoutEngine.new: deps parameter is required") - assert(deps.utils, "LayoutEngine.new: deps.utils is required") - assert(deps.Grid, "LayoutEngine.new: deps.Grid is required") - - -- Extract enums from utils local enums = deps.utils.enums local Positioning = enums.Positioning local FlexDirection = enums.FlexDirection @@ -59,11 +53,13 @@ function LayoutEngine.new(props, deps) local AlignItems = enums.AlignItems local AlignSelf = enums.AlignSelf local FlexWrap = enums.FlexWrap - + local self = setmetatable({}, LayoutEngine) - + -- Store dependencies for instance methods self._Grid = deps.Grid + self._Units = deps.Units + self._Gui = deps.Gui self._Positioning = Positioning self._FlexDirection = FlexDirection self._JustifyContent = JustifyContent @@ -71,7 +67,7 @@ function LayoutEngine.new(props, deps) self._AlignItems = AlignItems self._AlignSelf = AlignSelf self._FlexWrap = FlexWrap - + -- Layout configuration self.positioning = props.positioning or Positioning.FLEX self.flexDirection = props.flexDirection or FlexDirection.HORIZONTAL @@ -80,16 +76,16 @@ function LayoutEngine.new(props, deps) self.alignContent = props.alignContent or AlignContent.STRETCH self.flexWrap = props.flexWrap or FlexWrap.NOWRAP self.gap = props.gap or 10 - + -- Grid layout configuration self.gridRows = props.gridRows self.gridColumns = props.gridColumns self.columnGap = props.columnGap self.rowGap = props.rowGap - + -- Element reference (will be set via initialize) self.element = nil - + return self end @@ -149,7 +145,7 @@ end --- Layout children within this element according to positioning mode function LayoutEngine:layoutChildren() local element = self.element - + if self.positioning == self._Positioning.ABSOLUTE or self.positioning == self._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 @@ -548,7 +544,7 @@ end ---@return number The calculated width function LayoutEngine:calculateAutoWidth() local element = self.element - + -- BORDER-BOX MODEL: Calculate content width, caller will add padding to get border-box local contentWidth = element:calculateTextWidth() if not element.children or #element.children == 0 then @@ -589,7 +585,7 @@ end ---@return number The calculated height function LayoutEngine:calculateAutoHeight() local element = self.element - + local height = element:calculateTextHeight() if not element.children or #element.children == 0 then return height @@ -625,4 +621,266 @@ function LayoutEngine:calculateAutoHeight() end end +--- Recalculate units based on new viewport dimensions (for vw, vh, % units) +---@param newViewportWidth number +---@param newViewportHeight number +function LayoutEngine:recalculateUnits(newViewportWidth, newViewportHeight) + local element = self.element + local Units = self._Units + local Gui = self._Gui + + -- Get updated scale factors + local scaleX, scaleY = Gui.getScaleFactors() + + -- Recalculate border-box width if using viewport or percentage units (skip auto-sized) + -- Store in _borderBoxWidth temporarily, will calculate content width after padding is resolved + if element.units.width.unit ~= "px" and element.units.width.unit ~= "auto" then + local parentWidth = element.parent and element.parent.width or newViewportWidth + element._borderBoxWidth = Units.resolve(element.units.width.value, element.units.width.unit, newViewportWidth, newViewportHeight, parentWidth) + elseif element.units.width.unit == "px" and element.units.width.value and Gui.baseScale then + -- Reapply base scaling to pixel widths (border-box) + element._borderBoxWidth = element.units.width.value * scaleX + end + + -- Recalculate border-box height if using viewport or percentage units (skip auto-sized) + -- Store in _borderBoxHeight temporarily, will calculate content height after padding is resolved + if element.units.height.unit ~= "px" and element.units.height.unit ~= "auto" then + local parentHeight = element.parent and element.parent.height or newViewportHeight + element._borderBoxHeight = Units.resolve(element.units.height.value, element.units.height.unit, newViewportWidth, newViewportHeight, parentHeight) + elseif element.units.height.unit == "px" and element.units.height.value and Gui.baseScale then + -- Reapply base scaling to pixel heights (border-box) + element._borderBoxHeight = element.units.height.value * scaleY + end + + -- Recalculate position if using viewport or percentage units + if element.units.x.unit ~= "px" then + local parentWidth = element.parent and element.parent.width or newViewportWidth + local baseX = element.parent and element.parent.x or 0 + local offsetX = Units.resolve(element.units.x.value, element.units.x.unit, newViewportWidth, newViewportHeight, parentWidth) + element.x = baseX + offsetX + else + -- For pixel units, update position relative to parent's new position (with base scaling) + if element.parent then + local baseX = element.parent.x + local scaledOffset = Gui.baseScale and (element.units.x.value * scaleX) or element.units.x.value + element.x = baseX + scaledOffset + elseif Gui.baseScale then + -- Top-level element with pixel position - apply base scaling + element.x = element.units.x.value * scaleX + end + end + + if element.units.y.unit ~= "px" then + local parentHeight = element.parent and element.parent.height or newViewportHeight + local baseY = element.parent and element.parent.y or 0 + local offsetY = Units.resolve(element.units.y.value, element.units.y.unit, newViewportWidth, newViewportHeight, parentHeight) + element.y = baseY + offsetY + else + -- For pixel units, update position relative to parent's new position (with base scaling) + if element.parent then + local baseY = element.parent.y + local scaledOffset = Gui.baseScale and (element.units.y.value * scaleY) or element.units.y.value + element.y = baseY + scaledOffset + elseif Gui.baseScale then + -- Top-level element with pixel position - apply base scaling + element.y = element.units.y.value * scaleY + end + end + + -- Recalculate textSize if auto-scaling is enabled or using viewport/element-relative units + if element.autoScaleText and element.units.textSize.value then + local unit = element.units.textSize.unit + local value = element.units.textSize.value + + if unit == "px" and Gui.baseScale then + -- With base scaling: scale pixel values relative to base resolution + element.textSize = value * scaleY + elseif unit == "px" then + -- Without base scaling but auto-scaling enabled: text doesn't scale + element.textSize = value + elseif unit == "%" or unit == "vh" then + -- Percentage and vh are relative to viewport height + element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportHeight) + elseif unit == "vw" then + -- vw is relative to viewport width + element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, newViewportWidth) + elseif unit == "ew" then + -- Element width relative + element.textSize = (value / 100) * element.width + elseif unit == "eh" then + -- Element height relative + element.textSize = (value / 100) * element.height + else + element.textSize = Units.resolve(value, unit, newViewportWidth, newViewportHeight, nil) + end + + -- Apply min/max constraints (with base scaling) + local minSize = element.minTextSize and (Gui.baseScale and (element.minTextSize * scaleY) or element.minTextSize) + local maxSize = element.maxTextSize and (Gui.baseScale and (element.maxTextSize * scaleY) or element.maxTextSize) + + if minSize and element.textSize < minSize then + element.textSize = minSize + end + if maxSize and element.textSize > maxSize then + element.textSize = maxSize + end + + -- Protect against too-small text sizes (minimum 1px) + if element.textSize < 1 then + element.textSize = 1 -- Minimum 1px + end + elseif element.units.textSize.unit == "px" and element.units.textSize.value and Gui.baseScale then + -- No auto-scaling but base scaling is set: reapply base scaling to pixel text sizes + element.textSize = element.units.textSize.value * scaleY + + -- Protect against too-small text sizes (minimum 1px) + if element.textSize < 1 then + element.textSize = 1 -- Minimum 1px + end + end + + -- Final protection: ensure textSize is always at least 1px (catches all edge cases) + if element.text and element.textSize and element.textSize < 1 then + element.textSize = 1 -- Minimum 1px + end + + -- Recalculate gap if using viewport or percentage units + if element.units.gap.unit ~= "px" then + local containerSize = (self.flexDirection == self._FlexDirection.HORIZONTAL) and (element.parent and element.parent.width or newViewportWidth) + or (element.parent and element.parent.height or newViewportHeight) + element.gap = Units.resolve(element.units.gap.value, element.units.gap.unit, newViewportWidth, newViewportHeight, containerSize) + end + + -- Recalculate spacing (padding/margin) if using viewport or percentage units + -- For percentage-based padding: + -- - If element has a parent: use parent's border-box dimensions (CSS spec for child elements) + -- - If element has no parent: use element's own border-box dimensions (CSS spec for root elements) + local parentBorderBoxWidth = element.parent and element.parent._borderBoxWidth or element._borderBoxWidth or newViewportWidth + local parentBorderBoxHeight = element.parent and element.parent._borderBoxHeight or element._borderBoxHeight or newViewportHeight + + -- Handle shorthand properties first (horizontal/vertical) + local resolvedHorizontalPadding = nil + local resolvedVerticalPadding = nil + + if element.units.padding.horizontal and element.units.padding.horizontal.unit ~= "px" then + resolvedHorizontalPadding = + Units.resolve(element.units.padding.horizontal.value, element.units.padding.horizontal.unit, newViewportWidth, newViewportHeight, parentBorderBoxWidth) + elseif element.units.padding.horizontal and element.units.padding.horizontal.value then + resolvedHorizontalPadding = element.units.padding.horizontal.value + end + + if element.units.padding.vertical and element.units.padding.vertical.unit ~= "px" then + resolvedVerticalPadding = + Units.resolve(element.units.padding.vertical.value, element.units.padding.vertical.unit, newViewportWidth, newViewportHeight, parentBorderBoxHeight) + elseif element.units.padding.vertical and element.units.padding.vertical.value then + resolvedVerticalPadding = element.units.padding.vertical.value + end + + -- Resolve individual padding sides (with fallback to shorthand) + for _, side in ipairs({ "top", "right", "bottom", "left" }) do + -- Check if this side was explicitly set or if we should use shorthand + local useShorthand = false + if not element.units.padding[side].explicit then + -- Not explicitly set, check if we have shorthand + if side == "left" or side == "right" then + useShorthand = resolvedHorizontalPadding ~= nil + elseif side == "top" or side == "bottom" then + useShorthand = resolvedVerticalPadding ~= nil + end + end + + if useShorthand then + -- Use shorthand value + if side == "left" or side == "right" then + element.padding[side] = resolvedHorizontalPadding + else + element.padding[side] = resolvedVerticalPadding + end + elseif element.units.padding[side].unit ~= "px" then + -- Recalculate non-pixel units + local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth + element.padding[side] = + Units.resolve(element.units.padding[side].value, element.units.padding[side].unit, newViewportWidth, newViewportHeight, parentSize) + end + -- If unit is "px" and not using shorthand, value stays the same + end + + -- Handle margin shorthand properties + local resolvedHorizontalMargin = nil + local resolvedVerticalMargin = nil + + if element.units.margin.horizontal and element.units.margin.horizontal.unit ~= "px" then + resolvedHorizontalMargin = + Units.resolve(element.units.margin.horizontal.value, element.units.margin.horizontal.unit, newViewportWidth, newViewportHeight, parentBorderBoxWidth) + elseif element.units.margin.horizontal and element.units.margin.horizontal.value then + resolvedHorizontalMargin = element.units.margin.horizontal.value + end + + if element.units.margin.vertical and element.units.margin.vertical.unit ~= "px" then + resolvedVerticalMargin = + Units.resolve(element.units.margin.vertical.value, element.units.margin.vertical.unit, newViewportWidth, newViewportHeight, parentBorderBoxHeight) + elseif element.units.margin.vertical and element.units.margin.vertical.value then + resolvedVerticalMargin = element.units.margin.vertical.value + end + + -- Resolve individual margin sides (with fallback to shorthand) + for _, side in ipairs({ "top", "right", "bottom", "left" }) do + -- Check if this side was explicitly set or if we should use shorthand + local useShorthand = false + if not element.units.margin[side].explicit then + -- Not explicitly set, check if we have shorthand + if side == "left" or side == "right" then + useShorthand = resolvedHorizontalMargin ~= nil + elseif side == "top" or side == "bottom" then + useShorthand = resolvedVerticalMargin ~= nil + end + end + + if useShorthand then + -- Use shorthand value + if side == "left" or side == "right" then + element.margin[side] = resolvedHorizontalMargin + else + element.margin[side] = resolvedVerticalMargin + end + elseif element.units.margin[side].unit ~= "px" then + -- Recalculate non-pixel units + local parentSize = (side == "top" or side == "bottom") and parentBorderBoxHeight or parentBorderBoxWidth + element.margin[side] = Units.resolve(element.units.margin[side].value, element.units.margin[side].unit, newViewportWidth, newViewportHeight, parentSize) + end + -- If unit is "px" and not using shorthand, value stays the same + end + + -- BORDER-BOX MODEL: Calculate content dimensions from border-box dimensions + -- For explicitly-sized elements (non-auto), _borderBoxWidth/_borderBoxHeight were set earlier + -- Now we calculate content width/height by subtracting padding + -- Only recalculate if using viewport/percentage units (where _borderBoxWidth actually changed) + if element.units.width.unit ~= "auto" and element.units.width.unit ~= "px" then + -- _borderBoxWidth was recalculated for viewport/percentage units + -- Calculate content width by subtracting padding + element.width = math.max(0, element._borderBoxWidth - element.padding.left - element.padding.right) + elseif element.units.width.unit == "auto" then + -- For auto-sized elements, width is content width (calculated in resize method) + -- Update border-box to include padding + element._borderBoxWidth = element.width + element.padding.left + element.padding.right + end + -- For pixel units, width stays as-is (may have been manually modified) + + if element.units.height.unit ~= "auto" and element.units.height.unit ~= "px" then + -- _borderBoxHeight was recalculated for viewport/percentage units + -- Calculate content height by subtracting padding + element.height = math.max(0, element._borderBoxHeight - element.padding.top - element.padding.bottom) + elseif element.units.height.unit == "auto" then + -- For auto-sized elements, height is content height (calculated in resize method) + -- Update border-box to include padding + element._borderBoxHeight = element.height + element.padding.top + element.padding.bottom + end + -- For pixel units, height stays as-is (may have been manually modified) + + -- Detect overflow after layout calculations + if element._detectOverflow then + element:_detectOverflow() + end +end + return LayoutEngine diff --git a/modules/Renderer.lua b/modules/Renderer.lua index 0972cab..3dcad72 100644 --- a/modules/Renderer.lua +++ b/modules/Renderer.lua @@ -23,17 +23,6 @@ Renderer.__index = Renderer ---@param deps table Dependencies {Color, RoundedRect, NinePatch, ImageRenderer, ImageCache, Theme, Blur, utils} ---@return table Renderer instance function Renderer.new(config, deps) - -- Pure DI: Dependencies must be injected - assert(deps, "Renderer.new: deps parameter is required") - assert(deps.Color, "Renderer.new: deps.Color is required") - assert(deps.RoundedRect, "Renderer.new: deps.RoundedRect is required") - assert(deps.NinePatch, "Renderer.new: deps.NinePatch is required") - assert(deps.ImageRenderer, "Renderer.new: deps.ImageRenderer is required") - assert(deps.ImageCache, "Renderer.new: deps.ImageCache is required") - assert(deps.Theme, "Renderer.new: deps.Theme is required") - assert(deps.Blur, "Renderer.new: deps.Blur is required") - assert(deps.utils, "Renderer.new: deps.utils is required") - local Color = deps.Color local ImageCache = deps.ImageCache diff --git a/modules/ScrollManager.lua b/modules/ScrollManager.lua index d0070b3..3d874a2 100644 --- a/modules/ScrollManager.lua +++ b/modules/ScrollManager.lua @@ -39,10 +39,6 @@ ScrollManager.__index = ScrollManager ---@param deps table Dependencies {Color: Color module} ---@return ScrollManager function ScrollManager.new(config, deps) - -- Pure DI: Dependencies must be injected - assert(deps, "ScrollManager.new: deps parameter is required") - assert(deps.Color, "ScrollManager.new: deps.Color is required") - local Color = deps.Color local self = setmetatable({}, ScrollManager) diff --git a/modules/TextEditor.lua b/modules/TextEditor.lua index e2d8716..137dfc9 100644 --- a/modules/TextEditor.lua +++ b/modules/TextEditor.lua @@ -50,22 +50,15 @@ TextEditor.__index = TextEditor ---@param deps table Dependencies {GuiState, StateManager, Color, utils} ---@return table TextEditor instance function TextEditor.new(config, deps) - -- Pure DI: Dependencies must be injected - assert(deps, "TextEditor.new: deps parameter is required") - assert(deps.GuiState, "TextEditor.new: deps.GuiState is required") - assert(deps.StateManager, "TextEditor.new: deps.StateManager is required") - assert(deps.Color, "TextEditor.new: deps.Color is required") - assert(deps.utils, "TextEditor.new: deps.utils is required") - local self = setmetatable({}, TextEditor) - + -- Store dependencies self._GuiState = deps.GuiState self._StateManager = deps.StateManager self._Color = deps.Color self._FONT_CACHE = deps.utils.FONT_CACHE self._getModifiers = deps.utils.getModifiers - + -- Store configuration self.editable = config.editable or false self.multiline = config.multiline or false @@ -82,13 +75,13 @@ function TextEditor.new(config, deps) self.cursorColor = config.cursorColor self.selectionColor = config.selectionColor self.cursorBlinkRate = config.cursorBlinkRate or 0.5 - + -- 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 @@ -97,28 +90,28 @@ function TextEditor.new(config, deps) self._cursorVisible = true self._cursorBlinkPaused = false self._cursorBlinkPauseTimer = 0 - + -- Initialize selection state self._selectionStart = nil self._selectionEnd = nil self._selectionAnchor = nil - + -- Initialize focus state self._focused = false - + -- Initialize scroll state self._textScrollX = 0 - + -- 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 - + return self end @@ -126,7 +119,7 @@ end ---@param element table The parent Element instance function TextEditor:initialize(element) self._element = element - + -- Restore state from StateManager if in immediate mode if element._stateId and self._GuiState._immediateMode then local state = self._StateManager.getState(element._stateId) @@ -189,28 +182,28 @@ end function TextEditor:insertText(text, position) 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 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._cursorPosition = position + utf8.len(text) - + self:_markTextDirty() self:_updateTextIfDirty() self:_validateCursorPosition() @@ -223,25 +216,25 @@ end ---@param endPos number -- End position (inclusive) function TextEditor:deleteText(startPos, endPos) 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:_markTextDirty() self:_updateTextIfDirty() self:_resetCursorBlink(true) @@ -267,7 +260,7 @@ function TextEditor:_updateTextIfDirty() if not self._textDirty then return end - + self:_splitLines() self:_calculateWrapping() self:_validateCursorPosition() @@ -284,15 +277,15 @@ function TextEditor:_splitLines() self._lines = { self._textBuffer or "" } return end - + self._lines = {} local text = self._textBuffer or "" - + -- Split on newlines for line in (text .. "\n"):gmatch("([^\n]*)\n") do table.insert(self._lines, line) end - + -- Ensure at least one line if #self._lines == 0 then self._lines = { "" } @@ -305,10 +298,10 @@ function TextEditor:_calculateWrapping() self._wrappedLines = nil return end - + self._wrappedLines = {} local availableWidth = self._element.width - self._element.padding.left - self._element.padding.right - + for lineNum, line in ipairs(self._lines or {}) do if line == "" then table.insert(self._wrappedLines, { @@ -335,7 +328,7 @@ function TextEditor:_wrapLine(line, maxWidth) if not self._element then return { { text = line, startIdx = 0, endIdx = utf8.len(line) } } end - + -- Delegate to Renderer return self._element._renderer:wrapLine(self._element, line, maxWidth) end @@ -396,14 +389,14 @@ 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 @@ -419,7 +412,7 @@ function TextEditor:moveCursorToPreviousWord() end return text:sub(offset1, offset2 - 1) end - + -- Skip any whitespace/punctuation before current position while pos > 0 do local char = getCharAt(pos - 1) @@ -428,7 +421,7 @@ function TextEditor:moveCursorToPreviousWord() end pos = pos - 1 end - + -- Move to start of current word while pos > 0 do local char = getCharAt(pos - 1) @@ -437,7 +430,7 @@ function TextEditor:moveCursorToPreviousWord() end pos = pos - 1 end - + self._cursorPosition = pos self:_validateCursorPosition() end @@ -447,15 +440,15 @@ 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 @@ -471,7 +464,7 @@ function TextEditor:moveCursorToNextWord() end return text:sub(offset1, offset2 - 1) end - + -- Skip current word while pos < textLength do local char = getCharAt(pos) @@ -480,7 +473,7 @@ function TextEditor:moveCursorToNextWord() end pos = pos + 1 end - + -- Skip any whitespace/punctuation while pos < textLength do local char = getCharAt(pos) @@ -489,7 +482,7 @@ function TextEditor:moveCursorToNextWord() end pos = pos + 1 end - + self._cursorPosition = pos self:_validateCursorPosition() end @@ -506,12 +499,12 @@ end function TextEditor:_resetCursorBlink(pauseBlink) self._cursorBlinkTimer = 0 self._cursorVisible = true - + if pauseBlink then self._cursorBlinkPaused = true self._cursorBlinkPauseTimer = 0 end - + self:_updateTextScroll() end @@ -520,12 +513,12 @@ 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 @@ -535,7 +528,7 @@ function TextEditor:_updateTextScroll() end end local cursorX = font:getWidth(cursorText) - + -- Get available text area width local textAreaWidth = self._element.width local scaledContentPadding = self._element:getScaledContentPadding() @@ -543,18 +536,18 @@ function TextEditor:_updateTextScroll() 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 @@ -566,16 +559,16 @@ function TextEditor:_getCursorScreenPosition() 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 = "" @@ -587,14 +580,14 @@ function TextEditor:_getCursorScreenPosition() end return font:getWidth(cursorText), 0 end - + -- 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() @@ -602,7 +595,7 @@ function TextEditor:_getCursorScreenPosition() 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 first local lines = {} for line in (text .. "\n"):gmatch("([^\n]*)\n") do @@ -611,24 +604,24 @@ function TextEditor:_getCursorScreenPosition() 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 if cursorPos <= charCount + lineLength then 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 if posInLine >= segment.startIdx and posInLine <= segment.endIdx then local posInSegment = posInLine - segment.startIdx @@ -643,7 +636,7 @@ function TextEditor:_getCursorScreenPosition() end cursorX = font:getWidth(segmentText) cursorY = (lineNum - 1) * lineHeight + (segmentIdx - 1) * lineHeight - + return cursorX, cursorY end end @@ -663,10 +656,10 @@ function TextEditor:_getCursorScreenPosition() return cursorX, cursorY end end - + charCount = charCount + lineLength + 1 end - + -- Cursor is at the very end return 0, #lines * lineHeight end @@ -682,12 +675,12 @@ 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 @@ -727,25 +720,25 @@ 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 @@ -755,18 +748,18 @@ 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 @@ -779,21 +772,21 @@ function TextEditor:_getSelectionRects(selStart, selEnd) if not font or not self._element then return {} end - + local text = self._textBuffer or "" local rects = {} - + -- Apply password masking 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) @@ -801,16 +794,16 @@ function TextEditor:_getSelectionRects(selStart, selEnd) 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, handle line wrapping self:_updateTextIfDirty() - + -- Get text area width for wrapping local textAreaWidth = self._element.width local scaledContentPadding = self._element:getScaledContentPadding() @@ -818,7 +811,7 @@ function TextEditor:_getSelectionRects(selStart, selEnd) 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 @@ -827,77 +820,77 @@ function TextEditor:_getSelectionRects(selStart, selEnd) 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 local lineStartChar = charCount local lineEndChar = charCount + lineLength - + if selEnd > lineStartChar and selStart <= lineEndChar then local selStartInLine = math.max(0, selStart - charCount) local selEndInLine = math.min(lineLength, selEnd - charCount) - + if self.textWrap and textAreaWidth > 0 then local wrappedSegments = self:_wrapLine(line, textAreaWidth) - + for segmentIdx, segment in ipairs(wrappedSegments) do if selEndInLine > segment.startIdx and selStartInLine <= segment.endIdx then local segSelStart = math.max(segment.startIdx, selStartInLine) local segSelEnd = math.min(segment.endIdx, selEndInLine) - + 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 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 @@ -910,10 +903,10 @@ function TextEditor:_getSelectionRects(selStart, selEnd) visualLineNum = visualLineNum + 1 end end - + charCount = charCount + lineLength + 1 end - + return rects end @@ -929,39 +922,39 @@ function TextEditor:focus() self._GuiState._focusedElement._textEditor:blur() end end - + self._focused = true if self._element then self._GuiState._focusedElement = self._element end - + self:_resetCursorBlink() - + if self.selectOnFocus then self:selectAll() else self:moveCursorToEnd() 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 self._GuiState._focusedElement == self._element then self._GuiState._focusedElement = nil end - + if self.onBlur and self._element then self.onBlur(self._element) end - + self:_saveState() end @@ -981,7 +974,7 @@ function TextEditor:handleTextInput(text) if not self._focused then return end - + -- Trigger onTextInput callback if defined if self.onTextInput and self._element then local result = self.onTextInput(self._element, text) @@ -989,22 +982,22 @@ function TextEditor:handleTextInput(text) return end end - + local oldText = self._textBuffer - + -- Delete selection if exists if self:hasSelection() then self:deleteSelection() end - + -- Insert text at cursor position self:insertText(text) - + -- Trigger onTextChange callback if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end - + self:_saveState() end @@ -1016,16 +1009,16 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) if not self._focused then return end - + local modifiers = self._getModifiers() local ctrl = modifiers.ctrl or modifiers.super - + -- Handle cursor movement with selection if key == "left" or key == "right" or key == "home" or key == "end" or key == "up" or key == "down" then if modifiers.shift and not self._selectionAnchor then self._selectionAnchor = self._cursorPosition end - + if key == "left" then if modifiers.super then self:moveCursorToStart() @@ -1079,16 +1072,16 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) self:clearSelection() end end - + -- Update selection if Shift is pressed if modifiers.shift and self._selectionAnchor then self:setSelection(self._selectionAnchor, self._cursorPosition) elseif not modifiers.shift then self._selectionAnchor = nil end - + self:_resetCursorBlink() - + -- Handle backspace and delete elseif key == "backspace" then local oldText = self._textBuffer @@ -1107,12 +1100,11 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) self:deleteText(deleteStart, deleteEnd) self:_validateCursorPosition() end - + if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end self:_resetCursorBlink(true) - elseif key == "delete" then local oldText = self._textBuffer if self:hasSelection() then @@ -1123,12 +1115,12 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) self:deleteText(self._cursorPosition, self._cursorPosition + 1) end end - + if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end self:_resetCursorBlink(true) - + -- Handle return/enter elseif key == "return" or key == "kpenter" then if self.multiline then @@ -1137,7 +1129,7 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) self:deleteSelection() end self:insertText("\n") - + if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end @@ -1147,12 +1139,12 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) end end self:_resetCursorBlink(true) - + -- Handle Ctrl/Cmd+A (select all) elseif ctrl and key == "a" then self:selectAll() self:_resetCursorBlink() - + -- Handle Ctrl/Cmd+C (copy) elseif ctrl and key == "c" then if self:hasSelection() then @@ -1162,42 +1154,42 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) end end self:_resetCursorBlink() - + -- Handle Ctrl/Cmd+X (cut) elseif ctrl and key == "x" then if self:hasSelection() then local selectedText = self:getSelectedText() if selectedText then love.system.setClipboardText(selectedText) - + local oldText = self._textBuffer self:deleteSelection() - + if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end end end self:_resetCursorBlink(true) - + -- Handle Ctrl/Cmd+V (paste) elseif ctrl and key == "v" then local clipboardText = love.system.getClipboardText() if clipboardText and clipboardText ~= "" then local oldText = self._textBuffer - + if self:hasSelection() then self:deleteSelection() end - + self:insertText(clipboardText) - + if self.onTextChange and self._textBuffer ~= oldText and self._element then self.onTextChange(self._element, self._textBuffer, oldText) end end self:_resetCursorBlink(true) - + -- Handle Escape elseif key == "escape" then if self:hasSelection() then @@ -1207,7 +1199,7 @@ function TextEditor:handleKeyPress(key, scancode, isrepeat) end self:_resetCursorBlink() end - + self:_saveState() end @@ -1223,50 +1215,50 @@ 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 @@ -1275,9 +1267,9 @@ function TextEditor:mouseToTextPosition(mouseX, mouseY) if #lines == 0 then lines = { "" } end - + local lineHeight = font:getHeight() - + -- Get text area width local textAreaWidth = self._element.width local scaledContentPadding = self._element:getScaledContentPadding() @@ -1285,65 +1277,65 @@ function TextEditor:mouseToTextPosition(mouseX, mouseY) 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 @@ -1355,7 +1347,7 @@ 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) @@ -1366,7 +1358,7 @@ function TextEditor:handleTextClick(mouseX, mouseY, clickCount) elseif clickCount >= 3 then self:selectAll() end - + self:_resetCursorBlink() end @@ -1377,9 +1369,9 @@ 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 @@ -1387,7 +1379,7 @@ function TextEditor:handleTextDrag(mouseX, mouseY) else self:clearSelection() end - + self:_resetCursorBlink() end @@ -1397,14 +1389,14 @@ 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 @@ -1420,11 +1412,11 @@ function TextEditor:_selectWordAtPosition(position) 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) @@ -1433,7 +1425,7 @@ function TextEditor:_selectWordAtPosition(position) end startPos = startPos - 1 end - + -- Expand right to end of word while endPos < textLength do local char = getCharAt(endPos) @@ -1442,7 +1434,7 @@ function TextEditor:_selectWordAtPosition(position) end endPos = endPos + 1 end - + self:setSelection(startPos, endPos) self._cursorPosition = endPos end @@ -1457,7 +1449,7 @@ function TextEditor:update(dt) if not self._focused then return end - + -- Update cursor blink if self._cursorBlinkPaused then self._cursorBlinkPauseTimer = (self._cursorBlinkPauseTimer or 0) + dt @@ -1479,15 +1471,15 @@ function TextEditor:updateAutoGrowHeight() if not self.multiline or not self.autoGrow or not self._element 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 local textAreaWidth = self._element.width local scaledContentPadding = self._element:getScaledContentPadding() @@ -1495,7 +1487,7 @@ function TextEditor:updateAutoGrowHeight() 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 newlines local lines = {} for line in (text .. "\n"):gmatch("([^\n]*)\n") do @@ -1504,7 +1496,7 @@ function TextEditor:updateAutoGrowHeight() if #lines == 0 then lines = { "" } end - + -- Count total wrapped lines local totalWrappedLines = 0 if self.textWrap and textAreaWidth > 0 then @@ -1519,10 +1511,10 @@ function TextEditor:updateAutoGrowHeight() 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 @@ -1542,7 +1534,7 @@ function TextEditor:_getFont() if not self._element then return nil end - + -- Delegate to Renderer return self._element._renderer:getFont(self._element) end @@ -1552,7 +1544,7 @@ function TextEditor:_saveState() if not self._element or not self._element._stateId or not self._GuiState._immediateMode then return end - + self._StateManager.updateState(self._element._stateId, { _focused = self._focused, _textBuffer = self._textBuffer, diff --git a/modules/ThemeManager.lua b/modules/ThemeManager.lua index 520941f..db45d0e 100644 --- a/modules/ThemeManager.lua +++ b/modules/ThemeManager.lua @@ -23,10 +23,6 @@ ThemeManager.__index = ThemeManager ---@param deps table Dependencies {Theme: Theme module} ---@return ThemeManager function ThemeManager.new(config, deps) - -- Pure DI: Dependencies must be injected - assert(deps, "ThemeManager.new: deps parameter is required") - assert(deps.Theme, "ThemeManager.new: deps.Theme is required") - local Theme = deps.Theme local self = setmetatable({}, ThemeManager)