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 }), 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 genuinely 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 — 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 — and 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

' 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, e.g. 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.
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.
function showListDialog(title as string, items as object, onResult = "" as string, defaultIndex = 0 as integer, selectedIndex = -1 as integer) as object
  dialog = createObject("roSGNode", "JRListDialog")
  dialog.title = title
  dialog.defaultIndex = defaultIndex
  dialog.selectedIndex = selectedIndex
  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 ONLY: 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.startQuickConnect is the reference and the only caller.
'
' 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 — all ten
  ' result.confirmed consumers in app code gate positively, so a superseded
  ' confirm is a no-op, never a half-action. (This deliberately 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 just found 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.
    '
    ' ⚠️ ONE INCUMBENT IS NOT SAFE TO SUPERSEDE, and it is on THIS channel now:
    ' VideoPlayerView's playback-error alert NAVIGATES on close. The blanket
    ' safety argument above ("all ten result.confirmed consumers gate
    ' positively") covers dialogs whose handler READS a value; it does not cover
    ' one whose handler ACTS. Superseding that alert delivers a cancelled result
    ' its handler cannot distinguish from Back, so it fires exitPlayback() — a
    ' goBack out from under the dialog this call is in the middle of mounting.
    ' The alert used to live on the modal channel, where this line could not
    ' reach it; the phase-4 migration moved it here, which is what made the
    ' hazard reachable rather than removing it. Do NOT "fix" that by skipping
    ' the supersede — the one-overlay invariant is what stops findNode resolving
    ' to a corpse. The fix belongs in the alert's owner, which has to stop
    ' navigating from inside its result handler. Tracked as
    ' docs/architecture/tech-debt.md#playback-error-dialog-dismissed-before-it-is-read.
    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 deliberately 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 TextButton's 200 minWidth plus spacing is 648, so the cap bites
' only on genuinely 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 (e.g. 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