source_GridView_gridPaging.bs
' When a library grid asks for its next page, and what it does when a page after its first
' fails. The first page has its own failure state instead (BaseGridView.onGridLoadFailed).
'
' WHEN TO ASK: keep a runway. A grid loads the rows near where the user is, not the whole
' library: every loaded item costs memory (512 MB devices), a server query, and render-thread
' time as it lands. How far ahead is enough depends on two things measured as the grid is used
' rather than fixed in advance: how long a page takes to arrive (server, network and device
' together) and how fast the user is moving down the rows. The next page is asked for when the
' rows left below the focused row would run out before a page could arrive; at rest, one
' screen of rows is kept ahead. Only one page is ever in flight.
'
' In source/ rather than in the view so a Rooibos suite can reach it: a component's functions
' are scoped to it. Same move as gridPage.bs.
namespace gridPaging
' Starting values, not measurements. Tune them from the per-grid log line
' (BaseGridView.logGridSession), which records the stalls and the rows loaded but never seen.
'
' RUNWAY_MARGIN: how much sooner than the bare estimate to ask: page times vary, and a stall
' costs the user more than an early page costs the device.
' IDLE_MS: a pause longer than this between focus moves ends a burst of scrolling: the
' speed of the last burst says nothing about the next.
' SPEED_WINDOW_MS: the scroll speed is the rate over this much of the latest movement, not
' between two single reports: rows can be reported milliseconds apart when the render
' thread catches up after a page lands (measured 2026-09-25 on a 512 MB Stick: one pair
' read as 158 rows/s), and a rate over a window counts such a row at its true share.
' MIN_SPAN_MS: the shortest span a rate is taken over, for the same reason.
' SMOOTHING: the weight of the newest page in the running average of page time.
const RUNWAY_MARGIN = 1.5
const IDLE_MS = 1000
const SPEED_WINDOW_MS = 1000
const MIN_SPAN_MS = 250
const SMOOTHING = 0.5
' How fast the user is moving down the rows, for a new query: not moving.
function newScrollTracker() as object
return { row: -1, atMs: -1, moves: [] }
end function
' The user is now at `row`.
'
' A report of the row already tracked is not a move and changes nothing: the grid reports
' its focused item again when focus comes back to it, and where a scroll comes to rest after
' its rows were already reported one by one (measured 2026-09-25).
'
' @param tracker - from newScrollTracker(); modified
' @param row - the row the user is at
' @param nowMs - a millisecond clock, the same one on every call
sub trackFocus(tracker as object, row as integer, nowMs as integer)
if row = tracker.row then return
tracker.moves.push({ row: row, atMs: nowMs })
' Moves older than the window go, but one of them stays as the rate's starting point.
while tracker.moves.count() > 2 and nowMs - tracker.moves[1].atMs > gridPaging.SPEED_WINDOW_MS
tracker.moves.shift()
end while
tracker.row = row
tracker.atMs = nowMs
end sub
' Rows per second down the grid now, over the latest movement; 0 once the user has paused.
' Only moving down counts: moving up returns to rows already loaded.
'
' @param tracker - from newScrollTracker()
' @param nowMs - the clock trackFocus was given
function scrollSpeed(tracker as object, nowMs as integer) as float
moves = tracker.moves
if moves.count() < 2 or nowMs - tracker.atMs > gridPaging.IDLE_MS then return 0.0
first = moves[0]
last = moves[moves.count() - 1]
rows = last.row - first.row
if rows <= 0 then return 0.0
span = last.atMs - first.atMs
if span < gridPaging.MIN_SPAN_MS then span = gridPaging.MIN_SPAN_MS
return rows * 1000.0 / span
end function
' How long a page takes to arrive, for a grid that has loaded none: unknown.
function newPageTimer() as object
return { averageMs: -1.0 }
end function
' A page arrived `ms` after it was asked for.
'
' @param timer - from newPageTimer(); modified
sub recordPageTime(timer as object, ms as integer)
if timer.averageMs < 0
timer.averageMs = ms * 1.0
else
timer.averageMs = gridPaging.SMOOTHING * ms + (1 - gridPaging.SMOOTHING) * timer.averageMs
end if
end sub
' How many loaded rows the grid wants below the focused row: the rows the user would pass
' while a page is on its way, with a margin, and never less than one screen.
'
' @param screenRows - rows the grid shows at once
' @param rowsPerSecond - from scrollSpeed()
' @param pageMs - from a page timer; negative when no page has been timed
function runwayRows(screenRows as integer, rowsPerSecond as float, pageMs as float) as integer
if pageMs < 0 then return screenRows
needed = rowsPerSecond * pageMs / 1000 * gridPaging.RUNWAY_MARGIN
rows = Int(needed)
if rows < needed then rows++
if rows < screenRows then return screenRows
return rows
end function
' Whether to ask for the next page now.
'
' @param focusedRow - the focused row
' @param lastLoadedRow - the last row holding a loaded item
' @param hasMore - whether the query has items the grid has not loaded
' @param isLoading - whether a page is in flight
' @param wantedRows - from runwayRows()
function shouldLoadMore(focusedRow as integer, lastLoadedRow as integer, hasMore as boolean, isLoading as boolean, wantedRows as integer) as boolean
if isLoading or not hasMore then return false
return lastLoadedRow - focusedRow < wantedRows
end function
' Whether the user has reached the last loaded row while the next page is on its way: the
' one moment a later page's loading is theirs to see.
function isWaitingAtEnd(focusedRow as integer, lastLoadedRow as integer, hasMore as boolean, isLoading as boolean) as boolean
return isLoading and hasMore and focusedRow >= lastLoadedRow
end function
' Whether the query has items the grid has not loaded yet. Never before its first page has
' landed: that page has its own failure state and Try again, and a new query's total is 0
' until then.
'
' @param loadedItems - items in the grid
' @param total - the query's total, counted once, on its first page
function hasMore(loadedItems as integer, total as integer) as boolean
return loadedItems > 0 and loadedItems < total
end function
' The last row holding a loaded item; -1 when nothing is loaded.
function lastRow(loadedItems as integer, columns as integer) as integer
if loadedItems <= 0 or columns <= 0 then return -1
return (loadedItems - 1) \ columns
end function
' The failure notice for a new query: nothing told yet.
function newFailureNotice() as object
return { isTold: false }
end function
' A later page failed. Whether to tell the user now, counted as told when so.
'
' Once, until a page loads again: moving near the end of the grid asks again, and each
' failed ask must not raise another toast. Never while the grid is not on screen: that is
' another screen, and the grid asks again once it is back (shouldLoadMore), where a failure
' is told.
'
' @param notice - from newFailureNotice(); modified
' @param isShown - whether the grid is the view on screen
function shouldTellPageFailure(notice as object, isShown as boolean) as boolean
if not isShown or notice.isTold then return false
notice.isTold = true
return true
end function
' A page loaded, so the next failure is news again.
'
' @param notice - from newFailureNotice(); modified
sub pageLoaded(notice as object)
notice.isTold = false
end sub
end namespace