import "pkg:/source/utils/cellLoad.bs"
import "pkg:/source/utils/viewport.bs"
' Texture Manager Utility
'
' State machine managing texture memory for RowList and MarkupGrid screens.
' Cell components (JRRowItem for RowList, GridItem for MarkupGrid) observe fields
' on the content root and load/unload textures based on the current state and
' their position.
'
' States:
' "init" : Content loading, layout unstable. Cells freeze current state (no-op).
' "active" : Normal operation. Short rows (≤ threshold) keep every item loaded.
' Long rows (> threshold) use horizontal buffer logic.
' "hidden" : Screen hidden (pushed behind another). Freeze/no-op: all textures
' stay loaded so returning is instant. renderTracking changes ignored.
' "destroyed" : Component being torn down. All cells force-unload unconditionally.
'
' Vertical range (loadedRowRange):
' 4-element array: [bufferStart, visibleStart, visibleEnd, bufferEnd]
' Buffer rows (TEXTURE_BUFFER_ROWS either side of visible, via viewport.rowWindow) keep ALL
' items loaded in active state.
' Visible rows use horizontal buffer for long rows (RowList only).
'
' Horizontal buffer (focusedColumns + textureVisibleWidth + textureItemSpacing):
' Only applies to RowList rows with > TEXTURE_BUFFER_THRESHOLD items.
' A window of TEXTURE_BUFFER_THRESHOLD columns keeps textures loaded (the visible slots
' plus the rest of the budget split left/right, wrapping), and the rest unload. See
' isColumnInTextureWindow.
' Not used for MarkupGrid (all columns fit on screen).
'
' Flow (RowList):
' 1. Parent calls initTextureManager() after creating content root
' 2. Parent calls updateTextureBufferRange() after skeleton rows + on every focus change
' 3. Parent calls activateTextureManager() once initial content is fully loaded
' 4. Parent calls hideTextureManager() on onScreenHidden
' 5. Parent calls activateTextureManager() on onScreenShown (after restoring range)
' 6. Parent calls destroyTextureManager() in onDestroy()
' 7. JRRowItem observes textureManagerState, loadedRowRange, focusedColumns
'
' Flow (MarkupGrid):
' Same as RowList but uses updateGridTextureBufferRange() instead of
' updateTextureBufferRange(). GridItem observes textureManagerState and
' loadedRowRange (no focusedColumns: no horizontal buffer for grids).
' Maximum items a row can have before horizontal buffer logic kicks in.
' Rows at or below this count skip the horizontal buffer: all items stay loaded
' when on screen because they fit comfortably in texture memory.
const TEXTURE_BUFFER_THRESHOLD = 20
' Rows kept loaded on each side of the visible rows (see viewport.rowWindow).
const TEXTURE_BUFFER_ROWS = 2
' Adds texture management fields to a RowList content root node.
' Sets initial state to "init", so cells will not unload during layout changes.
'
' @param contentRoot - the RowList's root ContentNode (m.top.content)
' @param itemSize - RowList itemSize field ([containerWidth, containerHeight])
' @param focusXOffset - RowList focusXOffset field (array like [96] or empty [])
' @param rowItemSpacing - RowList rowItemSpacing field (flat [x,y] or nested [[x,y]])
sub initTextureManager(contentRoot as object, itemSize as dynamic, focusXOffset as dynamic, rowItemSpacing as dynamic)
if not isValid(contentRoot) then return
visibleWidth = calculateTextureVisibleWidth(itemSize, focusXOffset)
itemSpacing = calculateTextureItemSpacing(rowItemSpacing)
if not contentRoot.hasField("loadedRowRange")
contentRoot.addFields({
loadedRowRange: [-1, -1, -1, -1],
focusedColumns: [],
textureVisibleWidth: visibleWidth,
textureItemSpacing: itemSpacing
})
else
' Fields may already exist from a previous init: update layout values
contentRoot.textureVisibleWidth = visibleWidth
contentRoot.textureItemSpacing = itemSpacing
end if
' textureManagerState is managed separately from the batch addFields because
' JRRowList pre-adds it on the content root (via its content observer) so
' the Program progress tick can subscribe to visibility transitions before
' initTextureManager runs. addFields() fails on duplicate field names, so
' keeping this out of the batch lets both call sites coexist safely.
if not contentRoot.hasField("textureManagerState")
contentRoot.addField("textureManagerState", "string", false)
end if
contentRoot.textureManagerState = "init"
' Attach the cell-load ledger to the same lifecycle. `m.top.subtype()` resolves to the
' CALLING component (these are free functions, so `m` is the caller's, the same
' mechanism `screenReadiness.bs` documents), which is what lets every screen name itself
' without a single call site changing. Every caller today is a component codebehind; the
' caller's-`m` rule does not hold inside a BrighterScript class method, so a future class
' caller would have to pass its name instead.
if isValid(m.top)
cellLoad.attach(contentRoot, m.top.subtype())
end if
end sub
' Calculates the usable row width for determining how many items fit on screen.
' @param itemSize - RowList itemSize field ([containerWidth, containerHeight])
' @param focusXOffset - RowList focusXOffset field (array like [96] or empty [])
' @return usable pixel width
function calculateTextureVisibleWidth(itemSize as dynamic, focusXOffset as dynamic) as integer
visibleWidth = 1920 ' fallback
if type(itemSize) = "roArray" and itemSize.count() > 0
visibleWidth = itemSize[0]
end if
if type(focusXOffset) = "roArray" and focusXOffset.count() > 0
visibleWidth = visibleWidth - focusXOffset[0]
end if
return visibleWidth
end function
' Extracts horizontal item spacing from the RowList rowItemSpacing field.
' @param rowItemSpacing - flat [x,y] or nested [[x,y]]
' @return horizontal spacing in pixels
function calculateTextureItemSpacing(rowItemSpacing as dynamic) as integer
spacing = 0
if type(rowItemSpacing) = "roArray" and rowItemSpacing.count() > 0
if type(rowItemSpacing[0]) = "roArray"
spacing = rowItemSpacing[0][0]
else
spacing = rowItemSpacing[0]
end if
end if
return spacing
end function
' How many item slots of one row are visible at once. A partly visible slot at the right
' edge counts, because it renders.
'
' @param visibleWidth - usable row width, from calculateTextureVisibleWidth
' @param slotWidth - the row's item width (the cell's own `width`)
' @param itemSpacing - horizontal gap between items, from calculateTextureItemSpacing
' @return visible slot count; 0 when the slot width cannot be used
function calculateTextureVisibleItemCount(visibleWidth as integer, slotWidth as integer, itemSpacing as integer) as integer
itemStride = slotWidth + itemSpacing
if slotWidth <= 0 or itemStride <= 0 then return 0
numVisible = int(visibleWidth / itemStride)
if visibleWidth mod itemStride > 0 then numVisible++
return numVisible
end function
' Whether one column of a long RowList row belongs to the horizontal texture window.
'
' The window keeps TEXTURE_BUFFER_THRESHOLD textures loaded: the visible slots plus the
' remaining budget split around them, the odd item going to the right. It wraps, because
' Home's rows use `fixedFocusWrap`: at column 0 the left half sits at the row's TAIL, which
' is the item drawn in the `focusXOffset` gap and one Left press away.
'
' Pure so the geometry can be pinned by unit tests. The column sets those tests assert were
' read off a Stick 4K with an RTA cell census (32- and 100-item portrait rows), so a change
' here that moves the window fails a test rather than needing a device to notice.
'
' @param colIndex - the column being asked about
' @param focusedCol - the row's focused column
' @param totalItems - item count of the row
' @param numVisible - from calculateTextureVisibleItemCount
' @return true when the column's texture should stay loaded
function isColumnInTextureWindow(colIndex as integer, focusedCol as integer, totalItems as integer, numVisible as integer) as boolean
' Rows that fit the budget keep everything loaded.
if totalItems <= TEXTURE_BUFFER_THRESHOLD then return true
if colIndex < 0 then return false
bufferBudget = TEXTURE_BUFFER_THRESHOLD - numVisible
if bufferBudget < 2 then bufferBudget = 2
leftBuffer = int(bufferBudget / 2)
rightBuffer = bufferBudget - leftBuffer
bufferStart = (focusedCol - leftBuffer + totalItems) mod totalItems
bufferEnd = focusedCol + numVisible - 1 + rightBuffer
if bufferEnd >= totalItems
' Wraps past the end: in range at the tail or the head
return colIndex >= bufferStart or colIndex <= (bufferEnd mod totalItems)
else if bufferStart > focusedCol
' Wraps before the start (focus near column 0, start near the end)
return colIndex >= bufferStart or colIndex <= bufferEnd
end if
return colIndex >= bufferStart and colIndex <= bufferEnd
end function
' Calculates and sets the loaded row range and focused column based on current focus.
' The vertical range includes all visible rows plus a 2-row buffer above and below.
'
' @param contentRoot - the RowList's root ContentNode (m.top.content)
' @param focusedRow - index of the currently focused row (rowItemFocused[0])
' @param focusedColumn - index of the currently focused column (rowItemFocused[1])
' @param numVisibleRows - number of rows visible on screen (RowList.numRows)
sub updateTextureBufferRange(contentRoot as object, focusedRow as dynamic, focusedColumn as dynamic, numVisibleRows as integer)
if not isValid(contentRoot) or not contentRoot.hasField("loadedRowRange") then return
if not isValid(focusedRow) or not isValid(focusedColumn)
contentRoot.loadedRowRange = [-1, -1, -1, -1]
return
end if
totalRows = contentRoot.getChildCount()
if totalRows = 0 or focusedRow < 0 or focusedColumn < 0 or focusedRow >= totalRows
contentRoot.loadedRowRange = [-1, -1, -1, -1]
return
end if
' --- Vertical range ---
setLoadedRowRange(contentRoot, viewport.rowWindow(focusedRow, numVisibleRows, totalRows, TEXTURE_BUFFER_ROWS))
' --- Horizontal: track focused column per row ---
if contentRoot.hasField("focusedColumns")
cols = contentRoot.focusedColumns
' Extend array if needed (new rows added since last update)
while cols.count() <= focusedRow
cols.push(0)
end while
if cols[focusedRow] <> focusedColumn
cols[focusedRow] = focusedColumn
contentRoot.focusedColumns = cols
end if
end if
end sub
' Calculates and sets the loaded row range for a MarkupGrid based on current focus.
' Unlike updateTextureBufferRange() which works with hierarchical RowList content
' (children = rows), this works with flat MarkupGrid content (children = items)
' and derives row indices from the flat item index and column count.
'
' @param contentRoot - the MarkupGrid's root ContentNode (m.data / m.itemGrid.content)
' @param focusedIndex - flat index of the focused item (itemGrid.itemFocused)
' @param numColumns - number of columns in the grid (itemGrid.numColumns)
' @param numVisibleRows - number of rows visible on screen (itemGrid.numRows)
sub updateGridTextureBufferRange(contentRoot as object, focusedIndex as dynamic, numColumns as integer, numVisibleRows as integer)
if not isValid(contentRoot) or not contentRoot.hasField("loadedRowRange") then return
if not isValid(focusedIndex) or numColumns <= 0
contentRoot.loadedRowRange = [-1, -1, -1, -1]
return
end if
totalItems = contentRoot.getChildCount()
if totalItems = 0 or focusedIndex < 0 or focusedIndex >= totalItems
contentRoot.loadedRowRange = [-1, -1, -1, -1]
return
end if
' Derive row from flat index
focusedRow = int(focusedIndex / numColumns)
totalRows = int((totalItems + numColumns - 1) / numColumns) ' ceiling division
' --- Vertical range (same buffer as RowList) ---
setLoadedRowRange(contentRoot, viewport.rowWindow(focusedRow, numVisibleRows, totalRows, TEXTURE_BUFFER_ROWS))
end sub
' Writes `range` to loadedRowRange only when it differs, so cells observing the field are not
' woken by a focus move that leaves the window where it was.
sub setLoadedRowRange(contentRoot as object, range as object)
currentRange = contentRoot.loadedRowRange
if isValid(currentRange) and currentRange.count() >= 4 and currentRange[0] = range[0] and currentRange[1] = range[1] and currentRange[2] = range[2] and currentRange[3] = range[3]
return
end if
contentRoot.loadedRowRange = range
end sub
' Activates texture management after initial content load or on onScreenShown.
' Transitions state from "init" or "hidden" to "active", enabling buffer-based
' texture management. Callers should update loadedRowRange before calling this.
'
' @param contentRoot - the RowList's root ContentNode (m.top.content)
sub activateTextureManager(contentRoot as object)
if not isValid(contentRoot) or not contentRoot.hasField("textureManagerState") then return
contentRoot.textureManagerState = "active"
end sub
' Hides texture management when the screen is no longer visible.
' Freezes all cells in their current state: textures stay loaded so returning
' is instant. renderTracking changes are ignored while hidden.
'
' @param contentRoot - the RowList's root ContentNode (m.top.content)
sub hideTextureManager(contentRoot as object)
if not isValid(contentRoot) or not contentRoot.hasField("textureManagerState") then return
contentRoot.textureManagerState = "hidden"
' A screen being hidden is a COMPLETE viewing session (the user navigated away), and
' it is the only session boundary Home ever reaches, because the router SUSPENDS Home
' rather than destroying it.
cellLoad.emit(contentRoot)
end sub
' Destroys texture management during component teardown.
' All cells force-unload unconditionally: no guards, no buffer checks.
'
' @param contentRoot - the RowList's root ContentNode (m.top.content)
sub destroyTextureManager(contentRoot as object)
if not isValid(contentRoot) or not contentRoot.hasField("textureManagerState") then return
contentRoot.textureManagerState = "destroyed"
' No-ops when hideTextureManager already published this session (see cellLoad.emit).
cellLoad.emit(contentRoot)
end sub
' Decides whether a cell scrolling back into range should re-issue its image load.
'
' THREE paths leave a cell's poster at "" with the real URL still cached, and they do not
' all want the same thing:
' - unloadTexture() : the manager evicted a resident texture to free memory.
' - deferTextureLoad() : the cell bound while off screen, so no request was ever issued.
' - showPlaceholder() : the image FAILED, and the URI was cleared to reveal the type
' glyph underneath.
' The first two want the load; the third does not, because re-requesting a known-broken
' image wipes the glyph off a cell that is sitting still and then fails again. Deciding on
' `currentUri <> cachedUri` cannot separate them: all three look identical from there.
' `isTextureUnloaded` can: the first two set it, showPlaceholder does not.
'
' Worth knowing before "fixing" the missing retry: a failed image does not retry while its
' cell stays in range, only at the next unload or rebind. That is deliberate, and the
' measurement behind it is recorded as `failed-poster-no-scroll-retry` in
' docs/decisions.md.
function shouldReloadTexture(isTextureUnloaded as boolean, cachedUri as string) as boolean
if cachedUri = "" then return false
return isTextureUnloaded
end function