components_dialogs_JRDialog.bs

import "pkg:/source/roku_modules/log/LogMixin.brs"
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/misc.bs"

' Layout is DERIVED, not a stack of fixed offsets: every block is measured and
' the next one flows below it. Fixed offsets could not survive a title that
' wraps to two lines, a message of any length, or a button row whose width
' depends on the translation.
'
' The flow itself — the order of the blocks and the gaps between them — lives in
' source/utils/dialogLayout.bs, shared with JRListDialog and OverviewDialog and
' unit-tested there. What stays here is what is specific to THIS dialog: a panel
' that grows to fit its button row, and the row that made it grow.
const BUTTON_SPACING = 24
const MAX_BUTTONS = 3

' Width starts here and GROWS to fit the button row (long translations), up to
' PANEL_MAX_WIDTH. Past that the row stacks vertically instead of overflowing.
'
' Both on the 6px scale so the panel's left and right edges land on the 720p
' output grid: (1920 - w) / 2 = 960 - w/2, so a width divisible by 6 gives a
' panelX divisible by 3. 760 did not, which put both vertical edges off it.
const PANEL_WIDTH = 762
const PANEL_MAX_WIDTH = 1200

' The ONE piece of narration timing that cannot be event-driven.
'
' Established on-device, one behavior at a time:
'   - The platform announces the focused button ONLY when focus first enters the
'     dialog. Moving between buttons afterwards is silent (TextButton extends
'     Group, not Roku's Button, so the "text of button is spoken only if focused"
'     rule never applies). Those moves are ours alone, and need no timing.
'   - That one initial announcement FLUSHES ours. Speaking from the focus
'     observer, which is as early as any event lets us react, still loses: the
'     platform speaks after us and we are cut off. Speaking before focus loses
'     the same way.
' To survive we must speak AFTER the platform's announcement, and SceneGraph
' exposes no event for "the platform has finished dispatching speech" — the only
' observable speech events are for utterances the app itself starts. So the
' opening announcement is the one place a delay is unavoidable.
'
' Keep it short: long enough to land second, short enough to flush the platform's
' word before it is audible. At 0.6s the clipped word was clearly audible.
' Changing this needs an ear, not reasoning — there is no test that can catch it.
const OPENING_ANNOUNCEMENT_DELAY = 0.15



sub init()
  m.log = new log.Logger("JRDialog")

  m.chrome = m.top.findNode("chrome")
  ' The chrome measures the title and publishes its height; the flow cannot be
  ' computed until that exists.
  m.chrome.observeField("titleHeight", "onTitleMeasured")

  m.subheadingText = m.top.findNode("subheadingText")
  m.messageText = m.top.findNode("messageText")
  m.panelWidth = PANEL_WIDTH
  ' Layout depends on the rendered text heights — recompute when each settles
  m.subheadingText.enableRenderTracking = true
  m.subheadingText.observeField("renderTracking", "onSubheadingRendered")
  m.messageText.enableRenderTracking = true
  m.messageText.observeField("renderTracking", "onMessageRendered")

  m.buttonRow = m.top.findNode("buttonRow")
  ' Button sizing is observed per-button via TextButton's `isReady` (see
  ' onButtonsChanged), NOT via renderTracking. renderTracking reports render
  ' VISIBILITY, so it fires once on none -> full and never again — it cannot see
  ' the resize our own applyButtonLayout triggers when it switches the row to
  ' stacked, which left the stacked row positioned from stale measurements.

  m.buttonNodes = []
  m.stackButtons = false
  m.panelX = 0
  m.focusIndex = 0
  m.resolved = false

  ' Screen-reader announcement. Focus changes drive it (see
  ' onButtonFocusChanged); the opening announcement additionally needs the timer
  ' below, for the reason documented on OPENING_ANNOUNCEMENT_DELAY.
  m.buttonRow.observeField("focusedChild", "onButtonFocusChanged")
  m.narrationTimer = m.top.findNode("narrationTimer")
  m.narrationTimer.duration = OPENING_ANNOUNCEMENT_DELAY
  m.narrationTimer.observeField("fire", "onOpeningAnnouncementDue")
  m.hasNarrated = false

  applyLayout()
end sub

sub onTitleChanged()
  m.chrome.title = m.top.title
  ' The chrome re-measures and publishes titleHeight; onTitleMeasured lays out.
end sub

sub onTitleMeasured()
  applyLayout()
end sub

' SET THE TEXT FIELDS BEFORE PRESENTING, never after. `renderTracking` fires on
' the none -> full transition and never again (the same property that made the
' button row use TextButton's `isReady` instead), so once the dialog has rendered
' no later text change re-runs applyLayout — the new text draws on top of the old
' layout. True of `title` and `message` too; the helpers in source/utils/dialogs.bs
' set every field before presentOverlayDialog for exactly this reason.
sub onSubheadingChanged()
  m.subheadingText.text = m.top.subheading
  ' renderTracking fires once the new text lays out; layout() runs then
end sub

sub onSubheadingRendered()
  applyLayout()
end sub

sub onMessageChanged()
  m.messageText.text = m.top.message
  ' renderTracking fires once the new text lays out; layout() runs then
end sub

sub onMessageRendered()
  applyLayout()
end sub

sub onButtonsChanged()
  labels = m.top.buttons
  if not isValid(labels) then return
  if labels.count() > MAX_BUTTONS
    m.log.warn("JRDialog given", labels.count(), "buttons; design cap is", MAX_BUTTONS, "- use JRListDialog (showListDialog) for longer option sets")
  end if

  ' Rebuild the row
  while m.buttonRow.getChildCount() > 0
    m.buttonRow.removeChildIndex(0)
  end while
  m.buttonNodes = []

  for each label in labels
    btn = CreateObject("roSGNode", "TextButton")
    btn.text = label
    btn.minWidth = DIALOG_BUTTON_MIN_WIDTH
    ' Re-layout once this button knows its own size; the panel's width, height
    ' and the row's position are all derived from it.
    btn.observeField("isReady", "onButtonReady")
    m.buttonRow.appendChild(btn)
    m.buttonNodes.push(btn)
  end for

  m.focusIndex = m.top.defaultButtonIndex
  if m.focusIndex < 0 or m.focusIndex >= m.buttonNodes.count() then m.focusIndex = 0
  applyLayout()
end sub

' Screen-reader announcement, driven by the focus event itself.
'
' The platform announces the focused button the moment focus lands, and app
' speech and platform speech interrupt each other rather than queueing — so we
' have to speak second to be the one that survives. The focus change IS that
' moment, so reacting to it needs no guess about timing (an earlier version used
' a tuned delay; the value was a guess that would rot on different hardware).
'
' First focus = the dialog opening: announce the whole thing, folding in the
' button so it is one utterance in the built-in order. Later focus changes
' announce just the button the user moved to — nothing else has changed.
sub onButtonFocusChanged()
  focused = m.buttonRow.focusedChild
  if not isValid(focused) then return

  ' The opening announcement is the timer's job — it has to land after the
  ' platform's own. Starting the timer here (rather than when the buttons are
  ' built) means it is anchored to focus actually landing.
  if not m.hasNarrated
    m.narrationTimer.control = "start"
    return
  end if

  narrateFocusedElement(toString(focused.text))
end sub

' Announce the whole dialog, once the platform's own focus announcement is out of
' the way. Folds in the focused button so this is a single utterance in the
' built-in order: title, message, button.
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 = ""
  focused = m.buttonRow.focusedChild
  if isValid(focused) then focusedText = toString(focused.text)

  ' The subheading is part of the BODY, not a second title, so it is joined into
  ' the message rather than passed as its own block — the same shape
  ' OverviewDialog uses for its tagline.
  body = []
  if isValidAndNotEmpty(m.top.subheading) then body.push(m.top.subheading)
  if isValidAndNotEmpty(m.top.message) then body.push(m.top.message)

  narrateDialogOpening(m.top.title, body.join(". "), 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

' A button finished sizing itself. Re-run the whole layout, not just the
' centring: the panel's width and height and the row's position are all derived
' from the buttons' real measurements. Laying out once from an estimate left the
' bottom padding short, and left a stacked row positioned off the panel.
sub onButtonReady()
  applyLayout()
end sub

' Position the chrome. Called on any content change; safe to run repeatedly.
'
' Everything is measured then flowed, so a two-line title, a long message and a
' wide button row each push what follows instead of colliding with it.
sub applyLayout()
  if not isValid(m.panelWidth) then m.panelWidth = PANEL_WIDTH

  ' --- width: grow to fit the buttons, then stack ---------------------------
  ' A single German confirm label can be wider than the default panel. Grow the
  ' panel to hold the row; past PANEL_MAX_WIDTH stack the buttons vertically
  ' instead, which stays readable where an overflowing row does not.
  rowWidth = naturalButtonRowWidth()
  wanted = PANEL_WIDTH
  if rowWidth + (PANEL_PADDING * 2) > wanted then wanted = rowWidth + (PANEL_PADDING * 2)
  ' PANEL_WIDTH is on the 6px scale so its vertical edges land on the 720p grid,
  ' but a GROWN width is derived from a measured button row and lands anywhere —
  ' so the crispness that constant buys survives only if the growth is snapped
  ' too. Before the max check, so the clamp still wins (PANEL_MAX_WIDTH is on
  ' the scale itself).
  wanted = snapPanelWidthToScale(wanted)
  m.stackButtons = wanted > PANEL_MAX_WIDTH
  if m.stackButtons then wanted = PANEL_MAX_WIDTH
  m.panelWidth = wanted

  contentWidth = m.panelWidth - (PANEL_PADDING * 2)
  applyButtonLayout(contentWidth)
  m.subheadingText.width = contentWidth
  m.messageText.width = contentWidth
  ' The title cannot be measured until it knows how wide it may be.
  m.chrome.contentWidth = contentWidth

  ' --- measure the blocks this dialog owns ----------------------------------
  ' The title is the chrome's to measure; a height of 0 means it has not
  ' rendered yet, and its observer brings us back when it has.
  titleHeight = m.chrome.titleHeight
  if m.top.title.len() > 0 and titleHeight = 0 then return

  subheadingHeight = 0
  if m.top.subheading.len() > 0
    subheadingHeight = m.subheadingText.localBoundingRect().height
    ' Not rendered yet — renderTracking re-runs layout() once it settles
    if subheadingHeight = 0 then return
  end if

  textHeight = 0
  if m.top.message.len() > 0
    textHeight = m.messageText.localBoundingRect().height
    ' Not rendered yet — renderTracking re-runs layout() once it settles
    if textHeight = 0 then return
  end if

  layout = computeDialogLayout({
    panelWidth: m.panelWidth,
    titleHeight: titleHeight,
    subheadingHeight: subheadingHeight,
    bodyHeight: textHeight,
    footerHeight: buttonRowHeight()
  })

  ' --- ceiling ---------------------------------------------------------------
  ' Never exceed the screen. Reaching this needs ~600 characters of message,
  ' which means the caller wants showInfoDialog, not a confirm — so say so
  ' rather than silently swallowing the text.
  '
  ' `body.height` is what the flow could actually give us, which is NOT what we
  ' asked for when it overflowed. Truncate to that, rather than to a figure
  ' re-derived from PANEL_MAX_HEIGHT here: the panel now comes back already
  ' clamped, so panel.height - PANEL_MAX_HEIGHT is zero and this branch would
  ' truncate to the full text and loop.
  if layout.overflows
    constants = m.global.constants
    lineHeight = int(constants.fontSizeMedium * 1.25)
    if lineHeight < 1 then lineHeight = 1
    keepLines = int(layout.body.height / lineHeight)
    if keepLines < 1 then keepLines = 1

    if m.messageText.maxLines <> keepLines
      m.log.warn("JRDialog message too long for a dialog; truncating to", keepLines, "lines - use showInfoDialog for long-form text")

      ' PLACE THE PANEL BEFORE RETURNING. The clamped layout is already valid —
      ' it is what the flow could give us — so writing it here means the dialog
      ' is positioned whether or not anything re-enters, and a later pass simply
      ' refines it.
      '
      ' The old shape returned first and relied on the truncation re-running
      ' layout(). It does re-run today, but NOT by the route the comment claimed:
      ' `renderTracking` reports render VISIBILITY, so it fires once on
      ' none -> full and never again (tech-debt
      ' jrdialog-no-relayout-on-post-mount-change) and a maxLines change does not
      ' bring it back. What actually re-enters is a TextButton's `isReady`, which
      ' lands after this only because both helpers in source/utils/dialogs.bs set
      ' `buttons` AFTER `message`. That is an ordering accident, not a mechanism,
      ' and it is one helper edit away from leaving the panel unplaced.
      m.chrome.layout = layout
      m.messageText.maxLines = keepLines
      return
    end if

    ' Already capped at exactly this many lines and STILL over. `lineHeight` is
    ' an ESTIMATE (fontSizeMedium * 1.25), so a font whose real line box is
    ' taller re-derives the same cap forever — and a title tall enough to blow
    ' the ceiling on its own leaves body.height at 0, which pins keepLines at 1
    ' whatever the message says. Returning again would be the quiet failure:
    ' `m.chrome.layout` never gets written, so the dialog draws with unpositioned
    ' chrome and nothing says why. Fall through and place it. A panel a few
    ' pixels over the ceiling is visible and recoverable; one that was never laid
    ' out is neither.
    m.log.warn("JRDialog still over the ceiling at", keepLines, "lines; laying it out anyway")
  end if

  m.chrome.layout = layout
  m.subheadingText.translation = [layout.subheading.x, layout.subheading.y]
  m.messageText.translation = [layout.body.x, layout.body.y]

  m.buttonRowY = layout.footer.y
  m.panelX = layout.panel.x
  centerButtonRow()
  focusButton(m.focusIndex)
end sub

' Width the row wants at its natural size, before any panel growth.
function naturalButtonRowWidth() as integer
  if m.buttonNodes.count() = 0 then return 0
  total = 0
  for each btn in m.buttonNodes
    w = btn.localBoundingRect().width
    if w < DIALOG_BUTTON_MIN_WIDTH then w = DIALOG_BUTTON_MIN_WIDTH
    total += w
  end for
  return total + (BUTTON_SPACING * (m.buttonNodes.count() - 1))
end function

' Horizontal row normally; vertical, full-width buttons when they cannot fit.
sub applyButtonLayout(contentWidth as integer)
  if m.stackButtons = true
    m.buttonRow.layoutDirection = "vert"
    ' LEFT, not center: a centered LayoutGroup positions children about the
    ' group's origin rather than left-anchoring them, which put the stacked row
    ' half its own width off the panel. All stacked buttons are the same width,
    ' so left-anchored is visually identical and the row's rect stays honest.
    m.buttonRow.horizAlignment = "left"
    m.buttonRow.itemSpacings = [BUTTON_SPACING]
    for each btn in m.buttonNodes
      btn.minWidth = contentWidth
    end for
  else
    m.buttonRow.layoutDirection = "horiz"
    m.buttonRow.horizAlignment = "left"
    m.buttonRow.itemSpacings = [BUTTON_SPACING]
    for each btn in m.buttonNodes
      btn.minWidth = DIALOG_BUTTON_MIN_WIDTH
    end for
  end if
end sub

' Rendered height of the button row, or a computed fallback before it lays out
' (localBoundingRect is 0 until the TextButtons size themselves).
function buttonRowHeight() as integer
  if isValid(m.buttonRow)
    rect = m.buttonRow.localBoundingRect()
    if rect.height > 0 then return rect.height
  end if
  return dialogButtonFallbackHeight(m.global.constants.fontSizeMedium)
end function

' Centre the row on the PANEL, not the screen — they only coincided while the
' panel was a fixed width centred on screen.
sub centerButtonRow()
  if not isValid(m.buttonRowY) then return
  rect = m.buttonRow.localBoundingRect()
  rowWidth = rect.width
  if rowWidth = 0 then return
  m.buttonRow.translation = [m.panelX + ((m.panelWidth - rowWidth) / 2), m.buttonRowY]
end sub

sub focusButton(index as integer)
  if m.buttonNodes.count() = 0 then return
  if index < 0 then index = 0
  if index >= m.buttonNodes.count() then index = m.buttonNodes.count() - 1
  m.focusIndex = index
  m.buttonNodes[index].setFocus(true)
end sub

' Step button focus, WRAPPING at the ends to match JRButtonGroup — the app's other
' horizontal button row wraps, so a dialog that dead-ends feels broken.
'
' Narration of the newly focused button is handled by the focusedChild observer,
' so it covers remote-driven moves and programmatic ones alike.
sub moveButtonFocus(delta as integer)
  count = m.buttonNodes.count()
  if count = 0 then return

  ' + count before MOD so a leftward move from index 0 wraps to the end rather
  ' than going negative
  nextIndex = (m.focusIndex + delta + count) mod count
  focusButton(nextIndex)
end sub

' Resolve the dialog exactly once, then close. index = -1 means cancelled (back).
sub resolveDialog(index as integer)
  if m.resolved then return
  m.resolved = true

  m.top.result = buttonDialogResult(index, m.top.confirmIndex, m.top.buttons)

  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

' Dismiss this overlay. Appended directly to the scene by dialogs.bs, so closing is a
' removeChild from our parent + restoring focus to the opener (returnFocusTo).
' Mirrors OverviewDialog.closeDialog().
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

' The decision itself is buttonDialogKeyAction in source/utils/dialogKeys.bs,
' which is pure and unit-tested. Everything here is the doing.
function onKeyEvent(key as string, press as boolean) as boolean
  if not press then return false

  action = buttonDialogKeyAction(key, m.stackButtons = true)

  if action = "cancel"
    resolveDialog(-1)
  else if action = "resolve"
    resolveDialog(m.focusIndex)
  else if action = "stepBack"
    moveButtonFocus(-1)
  else if action = "stepForward"
    moveButtonFocus(1)
  end if

  ' Modal containment: nothing escapes to the scene behind us, including the
  ' "consume" action itself.
  return true
end function