mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-12 00:10:56 +02:00
597 lines
23 KiB
Lua
597 lines
23 KiB
Lua
---@class Context
|
||
local modulePath = (...):match("(.-)[^%.]+$")
|
||
local ZIndex = require(modulePath .. "ZIndex")
|
||
local Element = require(modulePath .. "Element")
|
||
local Context = {
|
||
topElements = {},
|
||
-- Base scale configuration
|
||
baseScale = nil, -- {width: number, height: number}
|
||
-- Current scale factors
|
||
scaleFactors = { x = 1.0, y = 1.0 },
|
||
defaultTheme = nil,
|
||
_focusedElement = nil,
|
||
_focusedElementId = nil, -- Stable id used to rehydrate focus across immediate-mode frames
|
||
_activeEventElement = nil,
|
||
_cachedViewport = { width = 0, height = 0 },
|
||
-- Immediate mode state
|
||
_immediateMode = false,
|
||
_frameNumber = 0,
|
||
_currentFrameElements = {},
|
||
_immediateModeState = nil, -- Will be initialized if immediate mode is enabled
|
||
_frameStarted = false,
|
||
_autoBeganFrame = false,
|
||
-- Z-index ordered element tracking for immediate mode
|
||
_zIndexOrderedElements = {}, -- Array of elements sorted by z-index (lowest to highest)
|
||
-- Focus management guard
|
||
_settingFocus = false,
|
||
-- Hook called whenever focus changes: function(element) or nil
|
||
_onFocusChanged = nil,
|
||
|
||
-- Navigation state
|
||
_navigationContext = {
|
||
lastFocusedElement = nil, -- For returning from modals
|
||
navigationMode = "sequential", -- "sequential" or "directional"
|
||
containerElement = nil, -- Current navigation container
|
||
},
|
||
|
||
initialized = false,
|
||
|
||
-- Expose internal hit-testing helpers for unit testing only.
|
||
-- These are populated below after their local definitions. They are NOT part
|
||
-- of the public API and must not be relied on by callers; they exist so the
|
||
-- shared hit-test core (the single place display:none guarding lives) can be
|
||
-- exercised directly by the test suite. Subsequent unified-event-routing
|
||
-- tasks consume these locals through the mode-agnostic query functions.
|
||
_test = {
|
||
pointHitsElement = nil,
|
||
elementHasScrollableOverflow = nil,
|
||
},
|
||
|
||
-- Debug draw overlay
|
||
_debugDraw = false,
|
||
_debugDrawKey = nil,
|
||
|
||
-- Initialization state tracking
|
||
---@type "uninitialized"|"initializing"|"ready"
|
||
_initState = "uninitialized",
|
||
---@type table[] Queue of {props: ElementProps, callback: function(element)|nil}
|
||
_initQueue = {},
|
||
|
||
-- Per-frame cache for findInteractiveAtPosition so Clickable.onUpdate's
|
||
-- per-element call (unified-event-routing task 05) doesn't re-walk the tree
|
||
-- + realloc + sort for every interactive element sharing the same cursor.
|
||
-- Invalidated explicitly by Context.clearInteractiveCache() at the start of
|
||
-- each flexlove.update (both modes) and in clearFrameElements (immediate
|
||
-- mid-frame rebuild). It also self-invalidates when the topElements table
|
||
-- reference changes (tests replace it per-case; immediate-mode beginFrame
|
||
-- reassigns it each frame), so direct callers that never go through
|
||
-- flexlove.update still see fresh results across tree swaps.
|
||
_interactiveLookupCache = {
|
||
valid = false,
|
||
x = nil,
|
||
y = nil,
|
||
result = nil,
|
||
topElementsRef = nil,
|
||
frameNumber = -1,
|
||
},
|
||
}
|
||
|
||
--- Check if a point hits an element, accounting for scroll offsets and display:none.
|
||
--- All mode-agnostic query functions use this as their single hit-test entry point,
|
||
--- ensuring fixes like display:none guarding apply everywhere.
|
||
---
|
||
--- This is the single canonical place where `element.display == false` short-
|
||
--- circuits hit testing. Parent-chain clipping/scroll-offset accumulation is
|
||
--- the caller's responsibility: callers walk the parent chain (using
|
||
--- `elementHasScrollableOverflow` to decide which ancestors clip) and pass the
|
||
--- accumulated scroll offset in here. Keeping the parent walk outside this core
|
||
--- lets retained-mode (recursive tree descent) and immediate-mode (flat
|
||
--- z-index list) callers share the exact same primitive bounds/display logic.
|
||
---@param element Element
|
||
---@param mx number Screen X coordinate
|
||
---@param my number Screen Y coordinate
|
||
---@param scrollOffsetX number? Accumulated scroll offset from parent chain
|
||
---@param scrollOffsetY number? Accumulated scroll offset from parent chain
|
||
---@return boolean hits
|
||
local function pointHitsElement(element, mx, my, scrollOffsetX, scrollOffsetY)
|
||
scrollOffsetX = scrollOffsetX or 0
|
||
scrollOffsetY = scrollOffsetY or 0
|
||
|
||
-- Skip display:none elements entirely
|
||
if element.display == false then
|
||
return false
|
||
end
|
||
|
||
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 adjustedX = mx + scrollOffsetX
|
||
local adjustedY = my + scrollOffsetY
|
||
|
||
return adjustedX >= bx and adjustedX <= bx + bw and adjustedY >= by and adjustedY <= by + bh
|
||
end
|
||
|
||
--- Check if an element has scrollable/clipped overflow (for scroll offset accumulation).
|
||
--- Returns true for `scroll`, `auto`, and `hidden` on either axis. These are the
|
||
--- overflow values that clip/translate descendant content and therefore require
|
||
--- scroll-offset compensation when hit testing descendants.
|
||
---@param element Element
|
||
---@return boolean
|
||
local function elementHasScrollableOverflow(element)
|
||
local overflowX = element.overflowX or element.overflow
|
||
local overflowY = element.overflowY or element.overflow
|
||
return overflowX == "scroll"
|
||
or overflowX == "auto"
|
||
or overflowY == "scroll"
|
||
or overflowY == "auto"
|
||
or overflowX == "hidden"
|
||
or overflowY == "hidden"
|
||
end
|
||
|
||
-- Expose the two core helpers for unit testing only (see Context._test above).
|
||
Context._test.pointHitsElement = pointHitsElement
|
||
Context._test.elementHasScrollableOverflow = elementHasScrollableOverflow
|
||
|
||
-- Public exposure of the canonical hit-test primitive so other modules
|
||
-- (e.g. FlexLove's `getElementAtPosition` / `_getTouchElementAtPosition`
|
||
-- tree walks) can share the single implementation of bounds + display:none
|
||
-- guarding instead of duplicating the `display == false` check inline.
|
||
-- This keeps "display == false" in exactly one place for hit-testing.
|
||
Context.pointHitsElement = pointHitsElement
|
||
Context.elementHasScrollableOverflow = elementHasScrollableOverflow
|
||
|
||
--- Find the first scrollable element at a screen position, regardless of mode.
|
||
--- This is the mode-agnostic successor to the two duplicated scrollable lookups
|
||
--- that previously lived inline in `flexlove.wheelmoved`:
|
||
--- * immediate mode — walked `Context._zIndexOrderedElements` in reverse and
|
||
--- re-implemented bounds + parent-chain clipping + scroll-offset math; and
|
||
--- * retained mode — recursed through `Context.topElements` with a private
|
||
--- `findScrollableAtPosition(elements, x, y)` helper.
|
||
--- Both paths now collapse into this single function, which routes every
|
||
--- hit test through `pointHitsElement` (the single place `display == false`
|
||
--- is guarded) and every scroll-offset decision through
|
||
--- `elementHasScrollableOverflow`. As a result display:none elements are never
|
||
--- returned in either mode, fixing the latent bug where the immediate-mode
|
||
--- path's `isPointInElement` did not skip display:none elements.
|
||
---
|
||
--- The retained-mode branch intentionally mirrors the original
|
||
--- `findScrollableAtPosition` helper's tree walk (deepest scrollable wins,
|
||
--- children checked before self) but is upgraded to thread accumulated scroll
|
||
--- offsets through `pointHitsElement` so nested scrolled containers are tested
|
||
--- against their visible position. The original helper is removed once
|
||
--- `flexlove.wheelmoved` is rerouted onto this function in task 04.
|
||
---@param x number Screen X coordinate
|
||
---@param y number Screen Y coordinate
|
||
---@return Element|nil The scrollable element, or nil
|
||
function Context.findScrollableAtPosition(x, y)
|
||
if Context.isImmediateMode() then
|
||
-- Immediate mode: iterate the z-index ordered list (reverse order =
|
||
-- topmost first). pointHitsElement supplies the bounds + display guard.
|
||
for i = #Context._zIndexOrderedElements, 1, -1 do
|
||
local element = Context._zIndexOrderedElements[i]
|
||
if pointHitsElement(element, x, y) then
|
||
local overflowX = element.overflowX or element.overflow
|
||
local overflowY = element.overflowY or element.overflow
|
||
if
|
||
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
|
||
and (element._overflowX or element._overflowY)
|
||
then
|
||
return element
|
||
end
|
||
end
|
||
end
|
||
return nil
|
||
else
|
||
-- Retained mode: recursive tree walk from topElements. Children are
|
||
-- checked before self (deepest scrollable wins); accumulated scroll
|
||
-- offsets are threaded through pointHitsElement so descendants of
|
||
-- scrolled containers are hit-tested against their translated position.
|
||
local function findInTree(elements, scrollOffsetX, scrollOffsetY)
|
||
scrollOffsetX = scrollOffsetX or 0
|
||
scrollOffsetY = scrollOffsetY or 0
|
||
for i = #elements, 1, -1 do
|
||
local element = elements[i]
|
||
if pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
|
||
if #element.children > 0 then
|
||
local childScrollOffsetX = scrollOffsetX
|
||
local childScrollOffsetY = scrollOffsetY
|
||
if elementHasScrollableOverflow(element) then
|
||
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
|
||
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
|
||
end
|
||
local childResult = findInTree(element.children, childScrollOffsetX, childScrollOffsetY)
|
||
if childResult then
|
||
return childResult
|
||
end
|
||
end
|
||
-- No descendant was scrollable — check self.
|
||
local overflowX = element.overflowX or element.overflow
|
||
local overflowY = element.overflowY or element.overflow
|
||
if
|
||
(overflowX == "scroll" or overflowX == "auto" or overflowY == "scroll" or overflowY == "auto")
|
||
and (element._overflowX or element._overflowY)
|
||
then
|
||
return element
|
||
end
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
return findInTree(Context.topElements)
|
||
end
|
||
end
|
||
|
||
--- Check whether immediate mode is active.
|
||
--- This is the single canonical accessor for the mode flag consumed throughout
|
||
--- the framework. Mode-aware branches elsewhere call this instead of reading
|
||
--- `Context._immediateMode` directly, so the literal mode flag only appears
|
||
--- here (its definition) and in StateManager (its mirrored storage) — never
|
||
--- scattered across Element / behaviors / managers (behavior-mode-unification
|
||
--- task 11).
|
||
---@return boolean
|
||
function Context.isImmediateMode()
|
||
return Context._immediateMode
|
||
end
|
||
|
||
---@return number, number -- scaleX, scaleY
|
||
function Context.getScaleFactors()
|
||
return Context.scaleFactors.x, Context.scaleFactors.y
|
||
end
|
||
|
||
--- Register an element in the z-index ordered tree (for immediate mode)
|
||
---@param element Element The element to register
|
||
function Context.registerElement(element)
|
||
if not Context.isImmediateMode() then
|
||
return
|
||
end
|
||
|
||
table.insert(Context._zIndexOrderedElements, element)
|
||
end
|
||
|
||
function Context.clearFrameElements()
|
||
Context._zIndexOrderedElements = {}
|
||
Context.clearInteractiveCache()
|
||
end
|
||
|
||
--- Compute the composite z-index key for an element.
|
||
--- rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ
|
||
---
|
||
--- ROOT_WEIGHT (10^10) gives the top-level ancestor's z-index 10 digits of significance.
|
||
--- DEPTH_WEIGHT (10^3) gives nesting depth 3 digits, ensuring children always sort above
|
||
--- their ancestors. The element's own z (capped to ±999 by ZIndex.clamp) fits within the
|
||
--- remaining 3 digits without interfering with the depth component.
|
||
---
|
||
--- These weights assume |z| <= ZIndex.MAX_Z and practical tree depths (< 10^7), which
|
||
--- keeps the composite key well within Lua's exact integer range (2^53 ≈ 9 × 10^15).
|
||
---
|
||
--- This is the SINGLE canonical z-index ordering function, used by both
|
||
--- sortElementsByZIndex (the immediate-mode flat list sort) and
|
||
--- findInteractiveAtPosition (the mode-agnostic occlusion sort). Keeping them
|
||
--- on the same key ensures the interactive topmost element matches the visual
|
||
--- draw order — a button in a z=50 MainMenu window must occlude a button in a
|
||
--- z=0 BottomBar even when both buttons default to own z=0.
|
||
local function getEffectiveZIndex(elem)
|
||
local ownZ = elem.z or 0
|
||
local rootZ = ownZ
|
||
local depth = 0
|
||
local current = elem.parent
|
||
while current do
|
||
rootZ = current.z or 0
|
||
depth = depth + 1
|
||
current = current.parent
|
||
end
|
||
return rootZ * ZIndex.ROOT_WEIGHT + depth * ZIndex.DEPTH_WEIGHT + ownZ
|
||
end
|
||
|
||
-- Public exposure so FlexLove.getElementAtPosition shares the single
|
||
-- implementation instead of duplicating the parent-chain walk as a closure.
|
||
Context.getEffectiveZIndex = getEffectiveZIndex
|
||
|
||
--- Sort elements by z-index (called after all elements are registered)
|
||
function Context.sortElementsByZIndex()
|
||
-- Precompute the composite key ONCE per element so the sort comparator is a
|
||
-- pure table lookup (O(1)) instead of re-walking the parent chain on every
|
||
-- O(N log N) comparison. This function runs every frame in immediate mode.
|
||
local elements = Context._zIndexOrderedElements
|
||
local zIndices = {}
|
||
for i = 1, #elements do
|
||
zIndices[elements[i]] = getEffectiveZIndex(elements[i])
|
||
end
|
||
table.sort(elements, function(a, b)
|
||
return zIndices[a] < zIndices[b]
|
||
end)
|
||
end
|
||
|
||
--- Find the topmost interactive element at a screen position, regardless of mode.
|
||
--- Replaces the former immediate-mode-only `Context.getTopElementAt()` (removed
|
||
--- in unified-event-routing task 05) and the retained-mode `_activeEventElement`
|
||
--- mechanism — both are now funneled through this single entry point.
|
||
---
|
||
--- In immediate mode this replaces Context.getTopElementAt() (which only worked
|
||
--- in immediate mode). In retained mode this provides the same role as the
|
||
--- _activeEventElement set by flexlove.getElementAtPosition().
|
||
---
|
||
--- An element is "interactive" if it has an onEvent handler, themeComponent, or is editable.
|
||
---@param x number Screen X coordinate
|
||
---@param y number Screen Y coordinate
|
||
---@return Element|nil The topmost interactive element, or nil
|
||
function Context.findInteractiveAtPosition(x, y)
|
||
-- Per-frame cache: Clickable.onUpdate runs this for every interactive
|
||
-- element under the same cursor, but the result for a given (x,y) is
|
||
-- identical across all of them within a single update pass. Returning a
|
||
-- cached element restores the old 1x/frame cost of the _activeEventElement
|
||
-- mechanism that task 05 replaced. Cache auto-invalidates when the
|
||
-- topElements table reference changes (so tests and mid-frame rebuilds get
|
||
-- fresh results) and is cleared explicitly per-frame in flexlove.update.
|
||
local cache = Context._interactiveLookupCache
|
||
if
|
||
cache.valid
|
||
and cache.x == x
|
||
and cache.y == y
|
||
and cache.topElementsRef == Context.topElements
|
||
and cache.frameNumber == Context._frameNumber
|
||
then
|
||
return cache.result
|
||
end
|
||
|
||
local interactiveCandidates = {}
|
||
|
||
local function collectInteractive(element, scrollOffsetX, scrollOffsetY)
|
||
scrollOffsetX = scrollOffsetX or 0
|
||
scrollOffsetY = scrollOffsetY or 0
|
||
|
||
if not pointHitsElement(element, x, y, scrollOffsetX, scrollOffsetY) then
|
||
return
|
||
end
|
||
|
||
-- Check if this element is interactive
|
||
if element.onEvent or element.themeComponent or element.editable then
|
||
table.insert(interactiveCandidates, element)
|
||
end
|
||
|
||
-- Recurse into children with accumulated scroll offset
|
||
local childScrollOffsetX = scrollOffsetX
|
||
local childScrollOffsetY = scrollOffsetY
|
||
if elementHasScrollableOverflow(element) then
|
||
childScrollOffsetX = childScrollOffsetX + (element._scrollX or 0)
|
||
childScrollOffsetY = childScrollOffsetY + (element._scrollY or 0)
|
||
end
|
||
|
||
for _, child in ipairs(element.children) do
|
||
collectInteractive(child, childScrollOffsetX, childScrollOffsetY)
|
||
end
|
||
end
|
||
|
||
-- Always traverse the tree (works in both modes — topElements exists always)
|
||
for _, element in ipairs(Context.topElements) do
|
||
collectInteractive(element)
|
||
end
|
||
|
||
-- Sort by composite z-index descending — topmost wins. The composite key
|
||
-- (rootZ * ROOT_WEIGHT + depth * DEPTH_WEIGHT + ownZ) matches the ordering
|
||
-- used by sortElementsByZIndex / _zIndexOrderedElements, so the interactive
|
||
-- topmost element matches the visual draw order. This is critical for the
|
||
-- game's multi-window layout: a button inside a z=50 MainMenu window must
|
||
-- occlude a button inside a z=0 BottomBar even when both buttons default to
|
||
-- own z=0. Sorting by own-z alone (the original implementation) couldn't
|
||
-- distinguish them, so the wrong window's button could win, leaving the
|
||
-- visible button's isActiveElement=false and clicks/hover dead.
|
||
local zIndices = {}
|
||
for _, el in ipairs(interactiveCandidates) do
|
||
zIndices[el] = getEffectiveZIndex(el)
|
||
end
|
||
table.sort(interactiveCandidates, function(a, b)
|
||
return zIndices[a] > zIndices[b]
|
||
end)
|
||
|
||
local result = interactiveCandidates[1]
|
||
|
||
cache.x = x
|
||
cache.y = y
|
||
cache.result = result
|
||
cache.topElementsRef = Context.topElements
|
||
cache.frameNumber = Context._frameNumber
|
||
cache.valid = true
|
||
|
||
return result
|
||
end
|
||
|
||
--- Invalidate the per-frame `findInteractiveAtPosition` cache.
|
||
--- Called once at the top of `flexlove.update` (the natural per-frame boundary
|
||
--- in both modes) and from `clearFrameElements` (immediate-mode mid-frame
|
||
--- rebuild). After invalidation the next lookup recomputes fresh.
|
||
function Context.clearInteractiveCache()
|
||
local cache = Context._interactiveLookupCache
|
||
cache.valid = false
|
||
cache.x = nil
|
||
cache.y = nil
|
||
cache.result = nil
|
||
cache.topElementsRef = nil
|
||
cache.frameNumber = -1
|
||
end
|
||
|
||
--- Set the focused element (centralizes focus management)
|
||
--- Automatically blurs the previously focused element if different
|
||
---@param element Element|nil The element to focus (nil to clear focus)
|
||
function Context.setFocused(element)
|
||
if Context._focusedElement == element then
|
||
return -- Already focused
|
||
end
|
||
|
||
-- Prevent re-entry during focus change
|
||
if Context._settingFocus then
|
||
return
|
||
end
|
||
Context._settingFocus = true
|
||
|
||
-- Save reference to previously focused element before updating
|
||
local oldFocusedElement = Context._focusedElement
|
||
|
||
-- Blur previously focused element
|
||
if oldFocusedElement and oldFocusedElement ~= element then
|
||
if oldFocusedElement._textEditor then
|
||
oldFocusedElement._textEditor:blur(oldFocusedElement)
|
||
end
|
||
end
|
||
|
||
-- Set new focused element and persist its id for immediate-mode rehydration
|
||
Context._focusedElement = element
|
||
Context._focusedElementId = element and (element.id ~= "" and element.id or nil) or nil
|
||
|
||
-- Notify any registered focus change hook (e.g. FocusIndicator)
|
||
if Context._onFocusChanged then
|
||
Context._onFocusChanged(element)
|
||
end
|
||
|
||
-- Focus the new element's text editor if it has one
|
||
if element and element._textEditor then
|
||
element._textEditor._focused = true
|
||
end
|
||
|
||
Context._settingFocus = false
|
||
end
|
||
|
||
--- Recursively search for an element by id in an element tree
|
||
---@param root Element The root element to start searching from
|
||
---@param targetId string The id to search for
|
||
---@return Element|nil The element with the matching id, or nil if not found
|
||
local function findElementById(root, targetId)
|
||
if root.id == targetId then
|
||
return root
|
||
end
|
||
for _, child in ipairs(root.children or {}) do
|
||
local found = findElementById(child, targetId)
|
||
if found then
|
||
return found
|
||
end
|
||
end
|
||
return nil
|
||
end
|
||
|
||
--- Rehydrate _focusedElement from _focusedElementId by scanning live elements.
|
||
--- Called at the start of getFocused() in immediate mode so stale references
|
||
--- are always replaced with the current-frame object before use.
|
||
function Context._rehydrateFocus()
|
||
if not Context._focusedElementId then
|
||
Context._focusedElement = nil
|
||
return
|
||
end
|
||
|
||
-- First, try a fast linear search through all registered elements
|
||
for _, elem in ipairs(Context._zIndexOrderedElements) do
|
||
if elem.id == Context._focusedElementId then
|
||
Context._focusedElement = elem
|
||
return
|
||
end
|
||
end
|
||
|
||
-- If not found, recursively search from top-level elements
|
||
-- This handles cases where elements may not be in _zIndexOrderedElements
|
||
for _, topLevel in ipairs(Context.topElements or {}) do
|
||
local found = findElementById(topLevel, Context._focusedElementId)
|
||
if found then
|
||
Context._focusedElement = found
|
||
return
|
||
end
|
||
end
|
||
|
||
-- Element with that id is not present this frame (e.g. screen changed)
|
||
Context._focusedElement = nil
|
||
end
|
||
|
||
--- Get the currently focused element
|
||
---@return Element|nil The focused element, or nil if none
|
||
function Context.getFocused()
|
||
if Context.isImmediateMode() then
|
||
Context._rehydrateFocus()
|
||
end
|
||
return Context._focusedElement
|
||
end
|
||
|
||
--- Clear focus from any element
|
||
function Context.clearFocus()
|
||
Context._focusedElementId = nil
|
||
Context.setFocused(nil)
|
||
end
|
||
|
||
--- Get all focusable elements in tab order, regardless of mode.
|
||
--- In immediate mode this extracts from _zIndexOrderedElements (flat, z-sorted).
|
||
--- In retained mode it walks the element tree (DOM order).
|
||
--- In both modes, display:none elements are excluded.
|
||
---@return table<Element> List of focusable elements in tab order
|
||
function Context.getFocusableElements()
|
||
local focusable = {}
|
||
|
||
local function isFocusable(elem)
|
||
if elem.display == false then
|
||
return false
|
||
end
|
||
-- Use Element:isFocusable() for consistent behavior
|
||
return Element.isFocusable(elem)
|
||
end
|
||
|
||
local function collectFromTree(elements)
|
||
for _, elem in ipairs(elements) do
|
||
if isFocusable(elem) then
|
||
table.insert(focusable, elem)
|
||
end
|
||
if #elem.children > 0 then
|
||
collectFromTree(elem.children)
|
||
end
|
||
end
|
||
end
|
||
|
||
if Context._immediateMode then
|
||
-- Immediate mode: _zIndexOrderedElements is already in z-index order (lowest first),
|
||
-- which approximates tab order for most UIs.
|
||
for _, elem in ipairs(Context._zIndexOrderedElements) do
|
||
if isFocusable(elem) then
|
||
table.insert(focusable, elem)
|
||
end
|
||
end
|
||
else
|
||
-- Retained mode: walk the top element trees in DOM order
|
||
collectFromTree(Context.topElements)
|
||
end
|
||
|
||
return focusable
|
||
end
|
||
|
||
-- ====================
|
||
-- Navigation Context
|
||
-- ====================
|
||
|
||
--- Push current focus onto stack (for modals/dialogs)
|
||
---@param element Element?
|
||
function Context.pushFocusStack(element)
|
||
Context._navigationContext.lastFocusedElement = Context._focusedElement
|
||
if element then
|
||
Context.setFocused(element)
|
||
end
|
||
end
|
||
|
||
--- Pop focus from stack (return from modal)
|
||
---@return Element?
|
||
function Context.popFocusStack()
|
||
local previous = Context._navigationContext.lastFocusedElement
|
||
Context._navigationContext.lastFocusedElement = nil
|
||
Context.setFocused(previous)
|
||
return previous
|
||
end
|
||
|
||
--- Set navigation container (scope for tab navigation)
|
||
---@param element Element?
|
||
function Context.setNavigationContainer(element)
|
||
Context._navigationContext.containerElement = element
|
||
end
|
||
|
||
--- Get navigation container
|
||
---@return Element?
|
||
function Context.getNavigationContainer()
|
||
return Context._navigationContext.containerElement
|
||
end
|
||
|
||
return Context
|