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