components_dialogs_JRListDialog.bs
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/dialogKeys.bs"
import "pkg:/source/utils/dialogLayout.bs"
import "pkg:/source/utils/dialogNarration.bs"
import "pkg:/source/utils/dialogResult.bs"
import "pkg:/source/utils/dialogReveal.bs"
import "pkg:/source/utils/listTheme.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"
' See JRListDialog.xml for the interaction contract. Chrome and its arithmetic
' live elsewhere (JRDialogPanel + source/utils/dialogLayout.bs); this file owns
' the list, the key model's effects, and resolution.
' Row height and spacing come from dialogLayout.bs (LIST_ROW_*), where the
' spacing gate covers them. LIST_ROW_SPACING matches JRLabelList's own default
' so the dialog's list has the same visual rhythm as every other list in the app.
const PANEL_WIDTH = 900
const MAX_VISIBLE_ROWS = 8
sub init()
m.log = new log.Logger("JRListDialog")
m.chrome = m.top.findNode("chrome")
m.chrome.contentWidth = PANEL_WIDTH - (PANEL_PADDING * 2)
' The chrome measures the title and publishes its height; layout can only be
' computed once that exists.
m.chrome.observeField("titleHeight", "onTitleMeasured")
m.optionList = m.top.findNode("optionList")
m.optionList.observeField("itemSelected", "onItemSelected")
applyListFocusChrome(m.optionList)
' floatingFocus, ALWAYS — never fixedFocusWrap. Roku's own wrap engages only
' "as long as the list contains enough items to fill the list", so the dialog's
' behaviour would change shape with its content. Wrapping is implemented here
' instead (see onKeyEvent), where it is unconditional and unit-tested.
m.optionList.vertFocusAnimationStyle = "floatingFocus"
m.resolved = false
' Screen-reader announcement — mirrors JRDialog: focus changes drive it, and
' the opening announcement additionally needs the timer (see
' OPENING_ANNOUNCEMENT_DELAY in JRDialog.bs for why it cannot be event-driven).
m.top.observeField("focusedChild", "onDialogFocusChanged")
m.narrationTimer = m.top.findNode("narrationTimer")
m.narrationTimer.duration = 0.15
m.narrationTimer.observeField("fire", "onOpeningAnnouncementDue")
m.hasNarrated = false
end sub
sub onTitleChanged()
m.chrome.title = m.top.title
end sub
sub onTitleMeasured()
applyLayout()
end sub
sub onItemsChanged()
items = m.top.items
if not isValid(items) then return
content = m.top.findNode("content")
' Rebuild on repeat assignment
while content.getChildCount() > 0
content.removeChildIndex(0)
end while
' The CURRENT option is marked on the row itself, so it stays visible once the
' user moves focus off it. selectedIndex — NOT defaultIndex, which is only
' which row to focus and falls back to 0. -1 marks nothing, which is what a
' choice dialog and a picker with no matching track both want.
selectedIndex = m.top.selectedIndex
index = 0
for each item in items
row = CreateObject("roSGNode", "ContentNode")
row.title = toString(item)
row.addFields({ isSelected: index = selectedIndex })
' The three visual markers (check glyph, surface, hue) are all sighted-only,
' so the current option is also announced. AUDIO_GUIDE_SUFFIX is a PREDEFINED
' ContentNode field that MarkupList speaks after the row title and the
' platform's own "1 of 3" navigation hint — see the per-node table in
' docs/DEVELOPER/media-playback/text-to-speech.md (rokudev/dev-doc).
'
' setField, not dot access: the field is absent from BrighterScript's
' roSGNodeContentNode type, so `row.AUDIO_GUIDE_SUFFIX = ...` fails
' validation with cannot-find-name. Same reason as JRKeyboardDialog.bs:30.
if index = selectedIndex
row.setField("AUDIO_GUIDE_SUFFIX", translate(translationKeys.LabelCurrentlySelected))
end if
content.appendChild(row)
index++
end for
applyLayout()
if m.top.defaultIndex > 0 and m.top.defaultIndex < items.count()
m.optionList.jumpToItem = m.top.defaultIndex
end if
m.optionList.setFocus(true)
end sub
sub applyLayout()
items = m.top.items
if not isValid(items) or items.count() = 0 then return
visibleRows = items.count()
if visibleRows > MAX_VISIBLE_ROWS then visibleRows = MAX_VISIBLE_ROWS
layout = computeDialogLayout({
panelWidth: PANEL_WIDTH,
titleHeight: m.chrome.titleHeight,
bodyHeight: listHeightFor(visibleRows),
footerHeight: 0
})
' The flow CLAMPS the body at the ceiling, so a body that did not fit comes
' back shorter than it asked for — and a list drawn at the height it wanted
' would then run past the panel's bottom padding. MAX_VISIBLE_ROWS keeps this
' out of reach for a one-line title (8 rows = 648 against ~705 available), but
' a title that wraps to two lines eats the margin. Drop rows to what the panel
' can hold and lay out again; the list scrolls, so every option stays
' reachable. Reading `body.height` back rather than re-deriving the ceiling is
' the contract every dialog in the family follows.
if layout.overflows
visibleRows = rowsThatFit(layout.body.height)
layout = computeDialogLayout({
panelWidth: PANEL_WIDTH,
titleHeight: m.chrome.titleHeight,
bodyHeight: listHeightFor(visibleRows),
footerHeight: 0
})
' Dropping rows fixes a body that was too tall; it cannot fix a TITLE that
' blows the ceiling on its own, which leaves the flow no body to give back
' and pins rowsThatFit at its floor of one row. Lay it out anyway — a panel
' slightly over is visible and recoverable — but SAY SO, because JRDialog
' warns in exactly this position and a silent list is the harder of the two
' to notice.
if layout.overflows
m.log.warn("JRListDialog still over the ceiling at", visibleRows, "row(s); laying it out anyway")
end if
end if
m.chrome.layout = layout
m.optionList.itemSize = [layout.body.width, LIST_ROW_HEIGHT]
m.optionList.itemSpacing = [0, LIST_ROW_SPACING]
m.optionList.numRows = visibleRows
m.optionList.translation = [layout.body.x, layout.body.y]
end sub
' A row is LIST_ROW_HEIGHT tall and the gaps sit BETWEEN rows, so n rows occupy
' n * height + (n - 1) * spacing. The two helpers below are inverses of each
' other; keeping them adjacent is what stops the pair drifting.
function listHeightFor(rows as integer) as integer
if rows < 1 then return 0
return (rows * LIST_ROW_HEIGHT) + ((rows - 1) * LIST_ROW_SPACING)
end function
' Most rows that fit in `available`. Add one spacing back before dividing,
' because the last row does not carry one. Never returns 0: a dialog with no
' visible rows is worse than one row over budget, and the caller has already
' established there is at least one option.
function rowsThatFit(available as integer) as integer
rows = int((available + LIST_ROW_SPACING) / (LIST_ROW_HEIGHT + LIST_ROW_SPACING))
if rows < 1 then return 1
return rows
end function
' Screen-reader announcement, driven by the focus event itself rather than a
' guessed delay (see JRDialog.onButtonFocusChanged for the full reasoning).
'
' Only the opening announcement is ours. The list is a real LabelList, which the
' platform narrates itself (focused row title plus a navigation hint), and there
' is nothing else in this dialog to announce.
sub onDialogFocusChanged()
if not m.hasNarrated then m.narrationTimer.control = "start"
end sub
' Announce the dialog once the platform's own focus announcement is out of the way.
sub onOpeningAnnouncementDue()
' FAILSAFE for the layout cover, riding a timer that already exists and already
' means "this dialog has been open for 0.15s". A dialog that never completes a
' layout would otherwise stay covered — i.e. invisible while still holding the
' remote. Uncovering it here means the worst case is the pre-existing broken
' paint, not a black screen. See dialogReveal.bs.
revealDialog(m.top)
if m.hasNarrated then return
m.hasNarrated = true
focusedText = ""
items = m.top.items
index = m.top.defaultIndex
if isValid(items) and index >= 0 and index < items.count()
focusedText = toString(items[index])
' If the row we open on is the current option, say so — otherwise the SAME
' row gets described two different ways depending on how you arrived at it.
' Measured on a Roku Ultra: opening said "Select video source. B&W 1080p
' h264", but arrowing away and back said "B&W 1080p h264, 1 of 2, currently
' selected". The platform's own row announcement carries the suffix; ours
' bypassed it, because this composes from `items` rather than from the row's
' ContentNode, and ours is the utterance that survives (see
' narrateDialogOpening for why it has to be).
'
' Conditional, because the two indexes DIVERGE: a choice dialog has nothing
' current at all, and a picker whose active track is missing from the list
' still focuses row 0. Announcing it unconditionally would tell a
' screen-reader user something the check and the surface do not.
if index = m.top.selectedIndex
focusedText += ", " + translate(translationKeys.LabelCurrentlySelected)
end if
end if
' The platform's "1 of 2" navigation hint is deliberately NOT reproduced here.
' It is unconditional for any ArrayGrid-derived list and not suppressible, so
' the user hears it the moment they move — but its wording is Roku's and
' localised by Roku, and a hand-rolled "{0} of {1}" would drift from it in
' every locale but English. Saying less beats saying it differently.
'
' No `message` field on this dialog — the title carries the whole prompt
narrateDialogOpening(m.top.title, "", focusedText)
end sub
' Lay out NOW, called by presentOverlayDialog once this dialog is attached.
'
' The dialog is hidden until this returns, so there is no renderTracking to wait
' on — and none is needed, because localBoundingRect() answers while hidden. See
' dialogReveal.bs.
sub settleLayout()
applyLayout()
end sub
sub onItemSelected()
resolveDialog(m.optionList.itemSelected)
end sub
' Resolve exactly once, then close. index = -1 means dismissed without choosing.
sub resolveDialog(index as integer)
if m.resolved then return
m.resolved = true
m.top.result = listDialogResult(index)
closeDialog()
end sub
' Cancel this dialog from OUTSIDE — see cancelOpenDialog() in source/utils/dialogs.bs.
' Routed through resolveDialog so the once-only guard and the standard cancelled
' result both apply: a third party clearing the screen must be indistinguishable
' from the user pressing Back, or the owner waiting on the result is stranded.
sub cancelDialog()
resolveDialog(-1)
end sub
' Same close mechanics as JRDialog / OverviewDialog: removeChild + returnFocusTo.
sub closeDialog()
returnFocusTo = m.top.returnFocusTo
parent = m.top.getParent()
if isValid(parent) then parent.removeChild(m.top)
if isValid(returnFocusTo) then returnFocusTo.setFocus(true)
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
itemCount = 0
if isValid(m.top.items) then itemCount = int(m.top.items.count())
action = listDialogKeyAction(key, m.optionList.itemFocused, itemCount)
if action = "dismiss"
resolveDialog(-1)
return true
end if
if action = "wrapToTop"
m.optionList.jumpToItem = 0
return true
end if
if action = "wrapToBottom"
m.optionList.jumpToItem = itemCount - 1
return true
end if
' "consume" — modal containment: nothing escapes to the scene behind us.
return true
end function