stable id - fixes input for immediate mode

This commit is contained in:
Michael Freno
2025-11-10 14:08:08 -05:00
parent ecf31fb574
commit a567b44e6c
12 changed files with 619 additions and 1169 deletions

View File

@@ -191,7 +191,7 @@ function Element.new(props)
-- Auto-generate ID in immediate mode if not provided
if Gui._immediateMode and (not props.id or props.id == "") then
self.id = StateManager.generateID(props)
self.id = StateManager.generateID(props, props.parent)
else
self.id = props.id or ""
end
@@ -346,6 +346,36 @@ function Element.new(props)
-- 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
@@ -396,7 +426,18 @@ function Element.new(props)
}
end
self.text = props.text
-- For editable elements, default text to empty string if not provided
if self.editable and props.text == nil then
self.text = ""
else
self.text = props.text
end
-- Sync self.text with restored _textBuffer for editable elements in immediate mode
if self.editable and Gui._immediateMode and self._textBuffer then
self.text = self._textBuffer
end
self.textAlign = props.textAlign or TextAlign.START
-- Image properties
@@ -4068,8 +4109,9 @@ function Element:_validateCursorPosition()
if not self.editable then
return
end
local textLength = utf8.len(self._textBuffer or "")
self._cursorPosition = math.max(0, math.min(self._cursorPosition, textLength))
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)
@@ -4239,6 +4281,10 @@ function Element:deleteSelection()
self:clearSelection()
self._cursorPosition = startPos
self:_validateCursorPosition()
-- Save state to StateManager in immediate mode
self:_saveEditableState()
return true
end
@@ -4272,6 +4318,9 @@ function Element:focus()
if self.onFocus then
self.onFocus(self)
end
-- Save state to StateManager in immediate mode
self:_saveEditableState()
end
--- Remove focus from this element
@@ -4291,6 +4340,9 @@ function Element:blur()
if self.onBlur then
self.onBlur(self)
end
-- Save state to StateManager in immediate mode
self:_saveEditableState()
end
--- Check if this element is focused
@@ -4302,6 +4354,21 @@ function Element:isFocused()
return self._focused == true
end
--- Save editable element state to StateManager (for immediate mode)
function Element:_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,
})
end
-- ====================
-- Input Handling - Text Buffer Management
-- ====================
@@ -4329,6 +4396,9 @@ function Element:setText(text)
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
@@ -4369,6 +4439,9 @@ function Element:insertText(text, position)
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
---@param startPos number -- Start position (inclusive)
@@ -4402,6 +4475,9 @@ function Element:deleteText(startPos, endPos)
self:_markTextDirty()
self:_updateTextIfDirty() -- Update immediately to recalculate lines/wrapping
self:_updateAutoGrowHeight() -- Then update height based on new content
-- Save state to StateManager in immediate mode
self:_saveEditableState()
end
--- Replace text in range
@@ -5272,6 +5348,9 @@ function Element:textinput(text)
if self.onTextChange and self._textBuffer ~= oldText then
self.onTextChange(self, self._textBuffer, oldText)
end
-- Save state to StateManager in immediate mode
self:_saveEditableState()
end
--- Handle key press (special keys)
@@ -5510,6 +5589,9 @@ function Element:keypressed(key, scancode, isrepeat)
end
self:_resetCursorBlink()
end
-- Save state to StateManager in immediate mode
self:_saveEditableState()
end
return Element

View File

@@ -131,8 +131,8 @@ function GuiState.getTopElementAt(x, y)
local function findInteractiveAncestor(elem)
local current = elem
while current do
-- An element is interactive if it has a callback or themeComponent
if current.callback or current.themeComponent then
-- An element is interactive if it has a callback, themeComponent, or is editable
if current.callback or current.themeComponent or current.editable then
return current
end
current = current.parent

View File

@@ -69,6 +69,13 @@ local function hashProps(props, visited, depth)
onTextChange = true,
onEnter = true,
userdata = true,
-- Dynamic input/state properties that should not affect ID stability
text = true, -- Text content changes as user types
placeholder = true, -- Placeholder text is presentational
editable = true, -- Editable state can be toggled dynamically
selectOnFocus = true, -- Input behavior flag
autoGrow = true, -- Auto-grow behavior flag
passwordMode = true, -- Password mode can be toggled
}
-- Collect and sort keys for consistent ordering
@@ -96,8 +103,9 @@ end
--- Generate a unique ID from call site and properties
---@param props table|nil Optional properties to include in ID generation
---@param parent table|nil Optional parent element for tree-based ID generation
---@return string
function StateManager.generateID(props)
function StateManager.generateID(props, parent)
-- Get call stack information
local info = debug.getinfo(3, "Sl") -- Level 3: caller of Element.new -> caller of generateID
@@ -109,16 +117,43 @@ function StateManager.generateID(props)
local source = info.source or "unknown"
local line = info.currentline or 0
-- Create ID from source file and line number
local baseID = source:match("([^/\\]+)$") or source -- Get filename
baseID = baseID:gsub("%.lua$", "") -- Remove .lua extension
local locationKey = baseID .. "_L" .. line
-- Create base location key from source file and line number
local filename = source:match("([^/\\]+)$") or source -- Get filename
filename = filename:gsub("%.lua$", "") -- Remove .lua extension
local locationKey = filename .. "_L" .. line
-- If we have a parent, use tree-based ID generation for stability
if parent and parent.id and parent.id ~= "" then
-- Count how many children the parent currently has
-- This gives us a stable sibling index
local siblingIndex = #(parent.children or {})
-- Generate ID based on parent ID + sibling position (NO line number for stability)
-- This ensures the same position in the tree always gets the same ID
local baseID = parent.id .. "_child" .. siblingIndex
-- Add property hash if provided (for additional differentiation at same position)
if props then
local propHash = hashProps(props)
if propHash ~= "" then
-- Use first 8 chars of a simple hash
local hash = 0
for i = 1, #propHash do
hash = (hash * 31 + string.byte(propHash, i)) % 1000000
end
baseID = baseID .. "_" .. hash
end
end
return baseID
end
-- No parent (top-level element): use call-site counter approach
-- Track how many elements have been created at this location
callSiteCounters[locationKey] = (callSiteCounters[locationKey] or 0) + 1
local instanceNum = callSiteCounters[locationKey]
baseID = locationKey
local baseID = locationKey
-- Add instance number if multiple elements created at same location (e.g., in loops)
if instanceNum > 1 then