components_dialogs_QuickConnectDialog.bs
import "pkg:/source/roku_modules/log/LogMixin.brs"
import "pkg:/source/translationKeys.bs"
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"
import "pkg:/source/utils/quickConnect.bs"
import "pkg:/source/utils/translate.bs"
' Layout is DERIVED and computed exactly ONCE, from the two blocks this dialog
' owns: the instruction and the code below it. Both are laid out through the
' shared flow in source/utils/dialogLayout.bs — the instruction goes in the
' SUBHEADING slot, because that slot's whole definition is "a lead line that sits
' inside the body's space, so the pair reads as one block", which is precisely
' the relationship between "enter this code" and the code. Using it means this
' component does no layout arithmetic of its own.
'
' Width matches JRDialog's 762 deliberately: this dialog carries about as much
' text as an alert and should be indistinguishable from one at a glance. That is
' asserted rather than trusted — see the panel-width spec, which compares this
' component's rendered panel against a rendered JRDialog rather than against the
' literal below, so the two cannot drift apart silently.
const PANEL_WIDTH = 762
' Mirrors JRDialog's OPENING_ANNOUNCEMENT_DELAY. Duplicated rather than shared
' because BrighterScript consts are file-scoped and JRDialog's is not visible
' here; the value is one number with one reason, documented in full at its
' original (the platform's own focus announcement FLUSHES ours, and SceneGraph
' exposes no event for "the platform has finished speaking").
const OPENING_ANNOUNCEMENT_DELAY = 0.15
sub init()
m.log = new log.Logger("QuickConnectDialog")
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.instructionText = m.top.findNode("instructionText")
m.codeLabel = m.top.findNode("quickConnectCode")
' Layout depends on the rendered text heights — recompute when each settles.
m.instructionText.enableRenderTracking = true
m.instructionText.observeField("renderTracking", "onInstructionRendered")
m.codeLabel.enableRenderTracking = true
m.codeLabel.observeField("renderTracking", "onCodeRendered")
m.buttonRow = m.top.findNode("buttonRow")
m.cancelButton = createObject("roSGNode", "TextButton")
m.cancelButton.text = translate(translationKeys.ButtonCancel)
m.cancelButton.minWidth = DIALOG_BUTTON_MIN_WIDTH
' Re-layout once the button knows its own size: the row's position and the
' panel's height are both derived from it. (TextButton's `isReady`, not
' renderTracking — see JRDialog.init for why those are not interchangeable.)
m.cancelButton.observeField("isReady", "onButtonReady")
m.buttonRow.appendChild(m.cancelButton)
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
m.panelX = 0
m.resolved = false
applyLayout()
end sub
' SET THE TEXT FIELDS BEFORE PRESENTING, never after — see the field comments in
' the XML. Each change handler only writes the label; `renderTracking` brings us
' back to applyLayout() once the new text has actually laid out.
sub onTitleChanged()
m.chrome.title = m.top.title
end sub
sub onTitleMeasured()
applyLayout()
end sub
sub onInstructionChanged()
m.instructionText.text = m.top.instruction
end sub
sub onInstructionRendered()
applyLayout()
end sub
sub onCodeChanged()
m.codeLabel.text = m.top.code
end sub
sub onCodeRendered()
applyLayout()
end sub
sub onButtonReady()
applyLayout()
end sub
' Position the chrome and the two body blocks. Safe to run repeatedly; it returns
' early until every block it needs has actually been measured.
sub applyLayout()
contentWidth = PANEL_WIDTH - (PANEL_PADDING * 2)
m.instructionText.width = contentWidth
m.codeLabel.width = contentWidth
' The title cannot be measured until it knows how wide it may be.
m.chrome.contentWidth = contentWidth
' A height of 0 means the block 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
instructionHeight = 0
if m.top.instruction.len() > 0
instructionHeight = m.instructionText.localBoundingRect().height
if instructionHeight = 0 then return
end if
codeHeight = 0
if m.top.code.len() > 0
codeHeight = m.codeLabel.localBoundingRect().height
if codeHeight = 0 then return
end if
layout = computeDialogLayout({
panelWidth: PANEL_WIDTH,
titleHeight: titleHeight,
subheadingHeight: instructionHeight,
bodyHeight: codeHeight,
footerHeight: buttonRowHeight()
})
' No overflow branch, unlike JRDialog: every string here is app-authored and
' short (one instruction sentence and a server-issued code), so the ~600
' characters it takes to reach PANEL_MAX_HEIGHT is not a shape a translation
' can produce. If that ever stops being true the panel grows past the screen
' visibly rather than silently, which is the direction that gets noticed.
m.chrome.layout = layout
m.instructionText.translation = [layout.subheading.x, layout.subheading.y]
m.codeLabel.translation = [layout.body.x, layout.body.y]
m.buttonRowY = layout.footer.y
m.panelX = layout.panel.x
centerButtonRow()
m.cancelButton.setFocus(true)
end sub
' Rendered height of the button row, or a computed fallback before it lays out
' (localBoundingRect is 0 until the TextButton sizes itself).
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.
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 + ((PANEL_WIDTH - rowWidth) / 2), m.buttonRowY]
end sub
' Announce the whole dialog once the platform's own focus announcement is out of
' the way — the timing is JRDialog's, measured on device.
'
' The code is spoken DIGIT BY DIGIT (see spokenQuickConnectCode): it is a string
' the listener has to transcribe onto another device, and a spaced digit string
' has exactly one pronunciation where a six-digit number has two.
sub onButtonFocusChanged()
focused = m.buttonRow.focusedChild
if not isValid(focused) then return
if not m.hasNarrated
m.narrationTimer.control = "start"
return
end if
narrateFocusedElement(toString(focused.text))
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
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)
body = []
if isValidAndNotEmpty(m.top.instruction) then body.push(m.top.instruction)
if isValidAndNotEmpty(m.top.code) then body.push(spokenQuickConnectCode(m.top.code))
narrateDialogOpening(m.top.title, body.join(". "), focusedText)
end sub
' Resolve exactly once, then close. This dialog has only one outcome of its own —
' the user backing out — so there is no index to pass: approval arrives at the
' OWNER from the server, which closes this dialog with abandonDialog() rather
' than through here.
sub resolveDialog()
if m.resolved then return
m.resolved = true
' index -1 = cancelled, and there is no confirm index. The empty `buttons`
' array is honest rather than lazy: buttonText is only ever read for a button
' the user SELECTED, and no path here selects one.
m.top.result = buttonDialogResult(-1, -1, [])
closeDialog()
end sub
' Cancel from OUTSIDE — cancelOpenDialog() and presentOverlayDialog's supersede.
' Routed through resolveDialog so the once-only guard and the standard cancelled
' result both apply.
sub cancelDialog()
resolveDialog()
end sub
' Dismiss this overlay: appended directly to the scene by dialogs.bs, so closing
' is a removeChild from our parent plus restoring focus to the opener.
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 is buttonDialogKeyAction in source/utils/dialogKeys.bs, the same
' model JRDialog uses. Everything here is the doing.
'
' This dialog has ONE button, so the model collapses: "cancel" and "resolve" are
' the same outcome (resolveDialog takes no index — backing out is the only thing
' the user can do from here), and stepping a one-button row lands back on the
' button it started from, so the step actions are no-ops rather than a focus
' call. `stacked` is false because a single short label never stacks.
'
' Sharing the model rather than hand-rolling two lines is the point: a horizontal
' row swallowing up/down under modal containment is a decision this dialog was
' making by accident, and it is now the same decision JRDialog makes on purpose.
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
action = buttonDialogKeyAction(key, false)
if action = "cancel" or action = "resolve" then resolveDialog()
' Modal containment: nothing escapes to the scene behind us, including the
' "consume" action and the two steps this dialog has nowhere to step to.
return true
end function