' Standard dialog helpers: the one way to show a dialog in JellyRock.
'
' All helpers create the dialog, present it, and return the dialog NODE. Result
' delivery is per-dialog-instance through the node's `result` field (shape:
' { cancelled, confirmed, buttonIndex, buttonText, optionIndex, value,
' externallyCancelled }), set
' exactly once when the user resolves the dialog. There is no shared global
' return field, so concurrent observers can never cross-fire.
'
' From a COMPONENT (render thread), pass onResult (the name of a function in
' YOUR component scope), and the helper wires the scoped observer for you:
'
' m.dialog = showConfirmDialog(tr("Delete"), tr("Are you sure?"), "onDeleteConfirm")
' sub onDeleteConfirm()
' result = m.dialog.result
' m.dialog = invalid
' if result.confirmed then deleteTheThing()
' end sub
'
' From MAIN THREAD code (main.bs), omit onResult and observe with your port
' instead: dialog.observeField("result", m.port).
'
' JRDialog / JRListDialog / QuickConnectDialog are scene-appended overlays
' (OverviewDialog mechanics); showKeyboardDialog uses Roku's modal channel
' (m.scene.dialog) because it wraps the OS keyboard. Callers don't need to care:
' the result contract is identical.
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/dialogLayout.bs"
import "pkg:/source/utils/dialogNarration.bs"
import "pkg:/source/utils/dialogReveal.bs"
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/translate.bs"
' Simple message + OK button, plus an OPTIONAL secondary action beside it.
'
' result.buttonIndex is 0 for OK, 1 for the secondary action, and the result is
' `cancelled` on back. A caller with no secondary state to unwind should treat
' cancelled exactly as it treats OK: back on a one-button alert has nothing to
' cancel back to.
'
' `subheading` is an optional BOLD lead line above the message, for a message
' that arrives in two parts: a heading and its text. Use it only when
' the two parts come from different places (the cast notice: JellyRock owns the
' provenance title, the sender owns the heading and the body). A message you
' wrote yourself belongs in one string.
'
' `secondaryButtonText` adds ONE extra button to the right of OK, for an action
' that does not leave the alert's subject: "Details" on an error, not a second
' answer to a question. Two answers to a question are showConfirmDialog.
'
' Not showChoiceDialog, which is the near miss a reader will reach for: it falls
' back to JRListDialog when the labels are wide (long translations), which would
' turn an error message into a scrollable list, and its contract says NOTHING is
' current yet. That is wrong for an alert whose primary action is already the
' default.
function showAlertDialog(title as string, message as string, onResult = "" as string, subheading = "" as string, secondaryButtonText = "" as string) as object
dialog = createObject("roSGNode", "JRDialog")
dialog.title = title
if subheading <> "" then dialog.subheading = subheading
dialog.message = message
buttons = [translate(translationKeys.ButtonOk)]
if secondaryButtonText <> "" then buttons.push(secondaryButtonText)
' OK is focused whether or not a secondary exists. The secondary is an EXTRA,
' never the thing a reflexive press should land on. Setting this before
' `buttons` matters, because assigning `buttons` is what builds and focuses the
' row (see JRDialog.onButtonsChanged).
dialog.defaultButtonIndex = 0
dialog.buttons = buttons
return presentOverlayDialog(dialog, onResult)
end function
' "Are you still watching?" (issue #982). One button, and ANY key answers it, Back
' included (anyKeyResolves). The question is only whether someone is there, so making
' them find a particular button would defeat it.
'
' `countdown` goes in the subheading slot, and the caller rewrites it every second. That
' is the one exception to setting text before presenting, and it is safe only because the
' slot holds a single short line: its height never changes, so the layout never has to
' (see JRDialog.onSubheadingChanged). The instruction stays in `message`, fixed.
'
' @param {string} countdown - the first countdown line ("Pausing in 30 seconds")
' @param {string} onResult - result handler in the caller's scope
function showStillWatchingDialog(countdown as string, onResult = "" as string) as object
dialog = createObject("roSGNode", "JRDialog")
dialog.title = translate(translationKeys.LabelAreYouStillWatching)
dialog.subheading = countdown
dialog.message = translate(translationKeys.MessagePressAnyButtonToKeepWatching)
dialog.anyKeyResolves = true
dialog.defaultButtonIndex = 0
dialog.buttons = [translate(translationKeys.ButtonContinueWatching)]
return presentOverlayDialog(dialog, onResult)
end function
' Yes/No-style confirmation. Cancel is LEFT, confirm is RIGHT; back = cancelled.
' Read result.confirmed: true only when the confirm button was selected.
function showConfirmDialog(title as string, message as string, onResult = "" as string, confirmText = "" as string, cancelText = "" as string) as object
if confirmText = "" then confirmText = translate(translationKeys.ButtonYes)
if cancelText = "" then cancelText = translate(translationKeys.ButtonNo)
dialog = createObject("roSGNode", "JRDialog")
dialog.title = title
dialog.message = message
dialog.confirmIndex = 1
dialog.defaultButtonIndex = 0 ' focus the safe (cancel) side by default
dialog.buttons = [cancelText, confirmText]
return presentOverlayDialog(dialog, onResult)
end function
' 2+ mutually exclusive options. Up to 3 short options render as the horizontal
' button row; more (or wider-than-panel labels, such as long translations) fall back
' to the scrollable list dialog. Either way the selection lands in
' result.optionIndex (-1 = cancelled), so callers don't care which route ran.
' bsc-disable-next-line dead-code keep: api: the pick-one member of the dialog family (#288); no feature has needed it yet
function showChoiceDialog(title as string, message as string, choices as object, onResult = "" as string, defaultIndex = 0 as integer) as object
if choicesFitAsButtons(choices)
dialog = createObject("roSGNode", "JRDialog")
dialog.title = title
dialog.message = message
dialog.defaultButtonIndex = defaultIndex
dialog.buttons = choices
return presentOverlayDialog(dialog, onResult)
end if
' selectedIndex left at its -1 default on purpose: a choice dialog is asking
' the user to pick, so NOTHING is current yet. Passing defaultIndex here would
' mark whichever option we happened to focus as the one already in effect,
' and the button route this falls back FROM shows no such marker, so the two
' presentations would stop meaning the same thing.
return showListDialog(title, choices, onResult, defaultIndex)
end function
' Select one item from a longer list (audio/subtitle tracks, video sources).
' result.optionIndex is the selected index, -1 on cancel.
'
' `defaultIndex` is which row to FOCUS. `selectedIndex` is which option is
' already CURRENT (it gets the check, the surface, and the "currently selected"
' announcement), and defaults to -1, meaning none is. A picker passes both (they
' normally coincide); anything offering a fresh choice passes only the first.
' `icons` is optional and positionally paired with `items`: an array of Poster
' URIs drawn in the row gutter the check would otherwise occupy. It exists for
' ACTION lists: a "More" overflow menu whose rows should wear the same glyphs as
' the buttons they stand in for. A picker passes none, because the gutter is
' already carrying its current-option check.
function showListDialog(title as string, items as object, onResult = "" as string, defaultIndex = 0 as integer, selectedIndex = -1 as integer, icons = invalid as object) as object
dialog = createObject("roSGNode", "JRListDialog")
dialog.title = title
dialog.defaultIndex = defaultIndex
dialog.selectedIndex = selectedIndex
' Before `items`, which is the field that triggers the row build.
if isValid(icons) then dialog.icons = icons
dialog.items = items
return presentOverlayDialog(dialog, onResult)
end function
' An overflow ("More") menu: the same JRListDialog, presented as a MENU instead
' of as a centered modal.
'
' Three presentation differences, and each is a fix for something the centred
' form got wrong here (#788):
'
' ANCHORED. A centred 900px panel covers the button row the menu belongs to AND
' the title/metadata column on the left. Anchored to the More button and grown
' upward, it sits over the backdrop art on the right and leaves both readable.
' It opens leftward from the button where it fits and flips rightward where it
' does not; computeDialogLayout owns that rule. `anchor` is { x, y, width } in
' screen coordinates; build it with moreButtonAnchor() so it cannot drift from
' the cap that produced the button.
'
' SIZED TO CONTENT. These rows are one-word actions. At the fixed width every
' label was stranded in a mostly-empty row.
'
' NO DRAWN TITLE. The menu hangs off a button already labelled More, so the
' heading repeated it and cost about 100px of height (figure from 2026-09-07).
' It is still ANNOUNCED: the title field is set, and narration reads that, not
' the panel.
'
' Everything else is JRListDialog: same chrome, same key model, same result
' contract, same specs. This is a presentation of the family's list dialog, not
' a second surface. See docs/architecture/dialogs.md for why that distinction
' is enforced rather than encouraged.
function showOverflowMenu(title as string, items as object, onResult as string, anchor as object, icons = invalid as object) as object
dialog = createObject("roSGNode", "JRListDialog")
dialog.title = title
dialog.titleVisible = false
dialog.sizeToContent = true
dialog.anchor = anchor
' selectedIndex is left at -1: these are ACTIONS, so no row is current. That
' is also what frees the row gutter for the buttons' own glyphs.
if isValid(icons) then dialog.icons = icons
' Before `items`, which is the field that triggers the row build.
dialog.items = items
return presentOverlayDialog(dialog, onResult)
end function
' Read-only long-form text (descriptions, biographies). Self-closing on OK/back;
' no result to observe. Wraps the existing OverviewDialog chrome.
'
' `returnFocusTo` names the node to restore focus to on close. Pass it when the
' OPENER is the right answer and you already hold it: a focusable element that
' expands into this dialog. Omitted, focus returns to whatever was focused when
' the dialog opened, which is the same node in most cases but is DERIVED rather
' than known.
function showInfoDialog(title as string, overview as string, tagline = "" as string, returnFocusTo = invalid as object) as object
dialog = createObject("roSGNode", "OverviewDialog")
if title <> "" then dialog.title = title
if tagline <> "" then dialog.tagline = tagline
dialog.overview = overview
' OverviewDialog has no `result` field. It self-closes on OK/back, so there is
' nothing to observe. Present it through the same path as the other overlays.
return presentOverlayDialog(dialog, "", returnFocusTo)
end function
' Show a structured read-only report: the same overlay as showInfoDialog, with a
' body of label/value rows instead of a paragraph.
'
' Returns the dialog so the caller can keep reporting into it: assigning
' `sections` again RECONCILES rather than rebuilds, which is how the playback
' report refreshes its live figures without disturbing the scroll position. The
' caller owns stopping that, and owns abandonDialog() in its onDestroy.
'
' `status` is the one-line verdict above the sections ("Transcoding"), carried in
' the tagline slot because that slot is already defined as a bold lead line and
' already read first by the opening announcement.
'
' @param {string} title - dialog title
' @param {string} status - a short lead line, or ""
' @param {object} sections - [ { id, heading, wideLabels, rows: [ { id, label, value } ] } ]
' @param {object} returnFocusTo - node to restore focus to on close
function showReportDialog(title as string, status as string, sections as object, returnFocusTo = invalid as object) as object
dialog = createObject("roSGNode", "OverviewDialog")
if title <> "" then dialog.title = title
if status <> "" then dialog.tagline = status
' Set BEFORE presenting, like every other dialog in the family: the panel is
' sized from the body, so a body arriving after mount would draw over a layout
' computed for nothing.
dialog.sections = sections
return presentOverlayDialog(dialog, "", returnFocusTo)
end function
' Show a Quick Connect code and wait for the user to approve it elsewhere.
'
' A view and nothing more: it displays `code` with a Cancel button and resolves
' cancelled if the user backs out. It does not poll and does not sign anyone in.
' The caller owns the flow (initiate, poll, exchange) because that is a chain of
' `fetchAsync` promises and the promise registry lives on the CALLER's `m`.
' UserSelect is the reference and the only caller: startQuickConnect runs the
' flow, and openQuickConnectDialog shows this view.
'
' The caller closes it on the success path with abandonDialog(): approval comes
' from the server, not from the user, so there is nothing to deliver back through
' `result`.
function showQuickConnectDialog(title as string, instruction as string, code as string, onResult = "" as string) as object
dialog = createObject("roSGNode", "QuickConnectDialog")
' Order matters only in that all of them are set before presenting: this
' dialog lays out once and never re-flows (see its XML field comments).
dialog.title = title
dialog.instruction = instruction
dialog.code = code
return presentOverlayDialog(dialog, onResult)
end function
' Text entry via the OS keyboard (voice-capable, certification-compliant).
' Entered text lands in result.value; result.cancelled distinguishes cancel from
' an intentionally empty entry. secure = true masks input for passwords.
'
' keyboardDomain drives the keyboard's VOICE dictation mode, so it must match the
' kind of text being entered ("email" / "numeric" / "alphanumeric" / "password" /
' "generic"). secure = true implies "password" unless the caller names a domain;
' otherwise a masked field would still dictate as free-form words.
function showKeyboardDialog(title as string, onResult = "" as string, defaultText = "" as string, secure = false as boolean, keyboardDomain = "" as string) as object
if keyboardDomain = ""
if secure then keyboardDomain = "password" else keyboardDomain = "generic"
end if
dialog = createObject("roSGNode", "JRKeyboardDialog")
dialog.setField("title", title)
dialog.setField("keyboardDomain", keyboardDomain)
if secure then dialog.secure = true
if defaultText <> "" then dialog.setField("text", defaultText)
if onResult <> "" then dialog.observeFieldScoped("result", onResult)
' Roku modal channel: the OS owns presentation and focus for keyboard dialogs
scene = appScene()
scene.dialog = dialog
return dialog
end function
' Is a scene-appended overlay dialog currently on screen?
'
' The JRDialog family does not use Roku's modal channel (m.scene.dialog), so
' anything asking "is a dialog open?" via that channel alone has been answering
' false for every dialog in the app since the family moved to scene overlays.
' SceneManager.isDialogOpen() folds this in; prefer that if you have it, because
' it covers the keyboard dialogs on the modal channel too.
function isOverlayDialogOpen() as boolean
scene = appScene()
if not isValid(scene) then return false
return isValid(scene.findNode(OVERLAY_DIALOG_ID))
end function
' Abandon a dialog this screen opened, delivering no result. Call from onDestroy
' for any dialog node you are still holding.
'
' Overlay dialogs are appended to the SCENE, not to the screen that opened them,
' so they outlive their opener: a routed view destroyed while one is open (deep
' link, session expiry, server switch: anything that navigates without the user
' pressing back) would leave a modal stranded over the incoming screen, with a
' scoped observer pointing at a torn-down scope and returnFocusTo pointing at a
' dead node. Safe to call with invalid / an already-closed dialog.
sub abandonDialog(dialog as object)
if not isValid(dialog) then return
' unobserveField, not unobserveFieldScoped: the scoped form removes only the
' CALLING component's connection, so it misses a main-thread dialog observed
' with `observeField("result", m.port)`. The unscoped form drops every
' observer on the field, scoped and port alike, which is what "abandon"
' means. That is a WIDENING, and it is safe only because a dialog has exactly
' one owner. Don't reuse abandonDialog() on a node several scopes observe.
dialog.unobserveField("result")
if dialog.isSubtype("StandardKeyboardDialog")
' Roku modal channel: the OS owns presentation; writing `close` dismisses it
dialog.close = true
return
end if
parent = dialog.getParent()
if isValid(parent) then parent.removeChild(dialog)
end sub
' Close whatever dialog is open, delivering a CANCELLED result to whoever opened
' it. The counterpart to abandonDialog(): use this when a THIRD PARTY needs the
' screen clear while the dialog's owner is still alive and waiting on a result.
'
' The distinction matters. abandonDialog() delivers NOTHING, which is right in
' onDestroy: the scope that would receive the result is being torn down. Here
' the owner is a main-thread flow that holds state until its dialog answers (the
' deep-link server switch holds the pending server and the stashed link), so
' closing without an answer strands it. A cancelled result is the same thing the
' user pressing Back would have produced.
'
' Covers BOTH channels, like isDialogOpen(): the scene-appended overlays cancel
' through their own once-only resolve guard, and Roku's modal channel resolves as
' cancelled on `close` (see JRKeyboardDialog.onWasClosed).
sub cancelOpenDialog()
scene = appScene()
if not isValid(scene) then return
overlay = scene.findNode(OVERLAY_DIALOG_ID)
if isValid(overlay) then overlay.callFunc("cancelDialog")
if isValid(scene.dialog) then scene.dialog.close = true
end sub
' ---- internals ----
' The id every overlay dialog is stamped with. One definition, because three
' things depend on being able to FIND the open dialog by it: isOverlayDialogOpen,
' cancelOpenDialog, and presentOverlayDialog's supersede below. It identifies
' exactly one node because presentOverlayDialog keeps it that way.
const OVERLAY_DIALOG_ID = "jrDialog"
' Append an overlay dialog to the scene, capture focus, wire the caller's
' result observer. onResult is resolved in the CALLER's scope because these
' helpers execute inside the importing component's script scope.
function presentOverlayDialog(dialog as object, onResult as string, returnFocusTo = invalid as object) as object
scene = appScene()
' EXACTLY ONE overlay dialog is on screen at a time, and it is this one.
'
' Roku's modal channel (m.scene.dialog) is single-slot: the OS replaces the
' incumbent. The overlay channel is not, so when the main-thread flows moved
' off the modal channel that invariant needed restoring here. Two overlays
' stacked share this id, leaving findNode resolving to the corpse and the
' lower dialog visible but deaf behind the upper one.
'
' The incumbent is SUPERSEDED, not dropped: cancelDialog() routes through its
' own once-only resolve guard, so its owner gets the same cancelled result the
' user pressing Back would have produced. Safe at every call site: every
' `result.confirmed` read in app code gates positively, so a superseded
' confirm is a no-op, never a half-action. (This reverses an
' earlier decision to warn and stack, which was correct only while there was
' no way to close someone else's dialog WITHOUT stranding them.)
'
' No call site needs a re-entrancy guard, main-thread ones included; the
' ordering that makes that true is at replayRoute.onServerSwitchDialogResult.
'
' The log line stays. Two overlays racing is still a signal about something
' upstream (two casts in flight), and arbitrating silently would discard it.
incumbent = scene.findNode(OVERLAY_DIALOG_ID)
if isValid(incumbent)
print "WARNING - presentOverlayDialog: a dialog was already open; superseding it with a cancelled result"
' Reusing the node we found above rather than calling cancelOpenDialog():
' that would re-run findNode against a render-thread-owned tree, and on the
' main-thread path every crossing is a rendezvous. It would also reach the
' MODAL channel, which must not be superseded. That channel has ONE writer
' left (showKeyboardDialog), and superseding it would silently discard what
' the user typed. (It was THREE: QuickConnectDialog and then the player's
' error dialog both moved onto this overlay channel and joined the
' supersede.) See docs/architecture/tech-debt.md#dialog-channels-unarbitrated.
'
' ⚠️ The blanket safety argument above covers dialogs whose handler READS a
' value. It does not cover one whose handler ACTS: VideoPlayerView's
' playback-error alert leaves the play route on close, so superseding it
' used to fire exitPlayback() from inside this very call, a goBack out from
' under the dialog being mounted. That is why the cancelled result now
' carries externallyCancelled (see source/utils/dialogResult.bs): an acting
' handler declines a close the user did not make, while every reading
' handler is untouched. Don't skip the supersede for such a dialog instead:
' the one-overlay invariant is what stops findNode resolving to a corpse.
incumbent.callFunc("cancelDialog")
end if
' AFTER the supersede, never before. lastFocusedChild walks focusedChild down
' to the deepest focused node, which with an incumbent still mounted resolves
' INSIDE the incumbent, so returnFocusTo would point at a node that is about
' to be detached, and closing this dialog would restore focus to a corpse.
' A caller that KNOWS its opener passes it instead and skips the derivation.
dialog.id = OVERLAY_DIALOG_ID
if isValid(returnFocusTo)
dialog.returnFocusTo = returnFocusTo
else
dialog.returnFocusTo = lastFocusedChild(scene)
end if
if onResult <> "" then dialog.observeFieldScoped("result", onResult)
' A dialog cannot place anything until it has measured its own content, so it
' is hidden across the append and shown once it has. Measuring does not need
' visibility (see dialogReveal.bs).
hideDialogUntilLaidOut(dialog)
scene.appendChild(dialog)
settleAndRevealDialog(dialog)
dialog.setFocus(true)
' Screen-reader narration is not done here: it has to happen after focus
' settles or the platform's own announcement flushes it, so each dialog
' component owns its own announcement. See source/utils/dialogNarration.bs.
return dialog
end function
' Mirrors JRDialog's MAX_BUTTONS design cap. Kept as its own constant because
' BrighterScript consts are file-scoped and the component's is not visible here.
const MAX_BUTTON_ROW_CHOICES = 3
' A CONSERVATIVE cap, not a mirror of any panel dimension.
'
' JRDialog flows its button row INSIDE the panel and grows the panel to fit,
' up to PANEL_MAX_WIDTH (1200), so 1104px of row would actually render. This
' stops well short of that: past ~900 a three-button row reads as a wall of
' chrome, and the scrollable list is the better presentation for that many
' words regardless of whether it would fit.
'
' Erring low only routes to the list dialog, which is the safe direction. Three
' buttons at DIALOG_BUTTON_MIN_WIDTH (204) plus spacing is 660, so the cap bites
' only on long labels.
'
' (This previously claimed to mirror JRDialog's PANEL_WIDTH and to describe a
' row centered UNDER the panel. Neither was true: PANEL_WIDTH is 762, and the
' row moved inside the panel in #757.)
const BUTTON_ROW_MAX_WIDTH = 900
' A choice set renders as JRDialog's horizontal button row only if it's short
' (<= 3) AND the measured row fits the screen with comfortable margins.
' Translations that overflow (such as long German labels) route to the list dialog.
'
' Measured with the system font at the size the button label actually renders at
' (constants.fontSizeMedium, applied by LabelMedium), not roFontRegistry's
' default size. Those differ, so the old estimate was wrong for every label.
' Still an estimate: a user on the downloaded fallback font (uiFontFallback) has
' slightly different metrics, but fontScaleFactor normalizes those toward the
' system font's, so the error stays small. Erring wide merely routes to the list
' dialog, which is the safe direction.
function choicesFitAsButtons(choices as object) as boolean
if not isValid(choices) or choices.count() < 1 or choices.count() > MAX_BUTTON_ROW_CHOICES then return false
reg = CreateObject("roFontRegistry")
font = reg.GetDefaultFont(m.global.constants.fontSizeMedium, false, false)
buttonPadding = 60 ' TextButton default padding (30) * 2
spacing = 24
totalWidth = 0
for each label in choices
labelWidth = font.GetOneLineWidth(toString(label), 9999) + buttonPadding
if labelWidth < DIALOG_BUTTON_MIN_WIDTH then labelWidth = DIALOG_BUTTON_MIN_WIDTH
totalWidth += labelWidth
end for
totalWidth += spacing * (choices.count() - 1)
return totalWidth <= BUTTON_ROW_MAX_WIDTH
end function