source_utils_dialogs.bs

' 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