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