import "pkg:/source/roku_modules/log/LogMixin.brs"
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"
' This dialog's OWN geometry. The vertical flow (padding, title, accent rule,
' gaps) is shared — see source/utils/dialogLayout.bs.
'
' WIDTH is this dialog's to choose; HEIGHT is not, and used to be. The panel was
' a fixed 1602x762 on the reasoning that a scrolling body cannot decide its own
' size. That is half true and it was the wrong half: the WIDTH is what decides
' where the text wraps, and the width is fixed, so the text's natural height is
' fully known before any height decision. So the panel is content-derived like
' every other dialog now, and the scroll viewport reads the body height the flow
' hands back — clamped at PANEL_MAX_HEIGHT, which is the point at which it
' scrolls. A short description gets a small panel instead of ~200px of dead
' space; a long one gets 924px, which is 162px MORE than the old fixed height.
'
' 1602 rather than 1600 because a width divisible by 6 puts the centred panelX
' on the 720p output grid ((1920 - w) / 2 = 960 - w/2); computeDialogLayout snaps
' panelY. 1600 put every edge off it — the most visible chrome in the app's
' largest panel.
const DIALOG_WIDTH = 1602
const SCROLL_STEP = 150
const SCROLLBAR_THUMB_WIDTH = 12
const SCROLLBAR_TRACK_WIDTH = 3
' 12, not 8: the text column's right edge is body.width - THUMB - MARGIN, so a
' margin off the 6px scale pushed that edge off the 720p grid.
const SCROLLBAR_MARGIN = 12
' Centring offset for the 3px track inside the 12px thumb gutter. The true
' centre is 4.5, which is both fractional AND off the grid — for a 3px hard edge
' that is the worst case, so it sits one and a half pixels right of centre
' instead, which nothing can see and the output grid can.
const SCROLLBAR_TRACK_INSET = 6
' Structured-body geometry. Both label columns are fixed rather than measured:
' measuring the widest label needs a rendered pass, and this dialog already
' learned the hard way that nothing may depend on which pass it is in. Both are
' multiples of 6 so the value column's left edge lands on the 720p output grid.
' Wide enough that no known label has to wrap, because wrapping is the expensive
' fix here: a wrapping label must be render-tracked (it could be the tallest thing
' in its row), and tracked nodes are quadratic — see trackReportNode.
'
' MEASURED on a Stick 4K, not estimated from character counts, which got this
' wrong in both directions — the longest string is not the widest one:
'
' Total Bitrate 179px the longest ENGLISH row label
' Taxa de bits total 250px pt_BR
' Formato dos pixeis 271px pt / gl / es_AR
' Format slikovnih pik 285px sl — the longest by CHARACTERS (20), fits
' Formatu-edukiontzia 302px eu — the only real translation over 300
' Hardware Acceleration 329px not a value we ship, but the shape of one
'
' 300 clipped `eu` by two pixels. 360 clears every string above with room to
' spare, stays a multiple of 6 so the value column's left edge lands on the 720p
' output grid, and costs the value column 60px it does not need — the longest
' value is a file path, which wraps to maxLines = 6 either way.
const REPORT_LABEL_WIDTH = 360
' Reason rows carry a Jellyfin reason code in the label column
' ("VideoRangeTypeNotSupported"), which needs roughly twice the room a "Codec"
' does. Measured on a Stick 4K, the widest code in Jellyfin's vocabulary renders
' at 449px ("VideoRangeTypeNotSupported"; "ContainerBitrateExceedsLimit" is
' longer in characters but narrower at 437px), so this carries ~100px of headroom
' for a code the server adds later. Reason codes are never translated, so unlike
' the column above that measurement holds in every locale.
const REPORT_WIDE_LABEL_WIDTH = 552
const REPORT_SECTION_SPACING = 24
' The itemSpacings the XML declares on #scrollContent, named here because
' clearReportNodes has to restore it after a structured body widened it.
const DEFAULT_BODY_SPACING = 20
const REPORT_ROW_SPACING = 6
sub init()
m.log = new log.Logger("OverviewDialog")
constants = m.global.constants
m.chrome = m.top.findNode("chrome")
m.chrome.contentWidth = DIALOG_WIDTH - (PANEL_PADDING * 2)
m.chrome.observeField("titleHeight", "onTitleMeasured")
m.textClip = m.top.findNode("textClip")
m.scrollContent = m.top.findNode("scrollContent")
m.taglineText = invalid
' The text column's width comes from the PANEL WIDTH alone, which is a
' constant — so it is known before any layout pass, and setting it here is what
' lets the text render (and be measured) before the panel height is decided.
' That ordering is the whole reason this dialog can be content-sized at all.
m.textAreaWidth = DIALOG_WIDTH - (PANEL_PADDING * 2) - SCROLLBAR_THUMB_WIDTH - SCROLLBAR_MARGIN
' Structured-body state. m.reportValueNodes maps a row id to the label node
' showing its value, which is what lets a refresh rewrite one figure in place
' instead of rebuilding the body and throwing away the scroll position.
m.reportValueNodes = {}
m.reportRowOrder = []
m.reportNodes = []
m.overviewText = m.top.findNode("overviewText")
m.overviewText.maxLines = 500
m.overviewText.width = m.textAreaWidth
' Enable render tracking once - observer fires when text is set/changed
m.overviewText.enableRenderTracking = true
m.overviewText.observeField("renderTracking", "onTextRendered")
m.scrollTrack = m.top.findNode("scrollTrack")
m.scrollTrack.width = SCROLLBAR_TRACK_WIDTH
m.scrollTrack.color = constants.colorBackgroundSecondary
m.scrollTrack.visible = false
m.scrollThumb = m.top.findNode("scrollThumb")
m.scrollThumb.width = SCROLLBAR_THUMB_WIDTH
m.scrollThumb.color = constants.colorBackgroundSecondary
m.scrollThumb.visible = false
m.okButton = m.top.findNode("okButton")
m.okButton.text = translate(translationKeys.ButtonOk)
m.okButton.minWidth = DIALOG_BUTTON_MIN_WIDTH
' The panel's height now includes the button, so the button's own height is an
' input to the layout rather than something read opportunistically. `isReady`
' is the signal TextButton publishes when it has sized itself — the same one
' JRDialog's row uses, and not renderTracking, which reports render visibility
' and fires once.
m.okButton.observeField("isReady", "onOkButtonReady")
' Observe focus changes to show/hide scrollbar
m.top.observeField("focusedChild", "onFocusChanged")
' Screen-reader announcement — mirrors JRDialog / JRListDialog. Without it this
' overlay is SILENT: a scene-appended Group gets only Scene's fallback rule,
' which speaks the focused descendant, and both things focus can land on here
' (the scroll area and the OK button) are Groups the platform never announces.
' See source/utils/dialogNarration.bs.
m.narrationTimer = m.top.findNode("narrationTimer")
m.narrationTimer.duration = 0.15
m.narrationTimer.observeField("fire", "onOpeningAnnouncementDue")
m.hasNarrated = false
' Scroll state
m.scrollPosition = 0
m.maxScroll = 0
m.contentHeight = 0
m.textAreaHeight = 0
' Set the moment the user scrolls or moves focus themselves — see
' applyScrollBounds, which re-places the OPENING focus on every layout pass
' until this is true.
m.userHasNavigated = false
applyLayout()
end sub
sub onTitleMeasured()
applyLayout()
end sub
sub onOkButtonReady()
applyLayout()
end sub
' Measure, then flow. Every input to the panel's height has to have settled
' before the panel can be sized, so each one that has not returns early and comes
' back through its own observer — the same measure-or-return shape JRDialog uses.
sub applyLayout()
titleHeight = m.chrome.titleHeight
if isValidAndNotEmpty(m.top.title) and titleHeight = 0 then return
' The button is INSIDE the panel now, so its height is part of the panel's.
' onOkButtonReady brings us back with the real figure.
footerHeight = okButtonHeight()
' The natural height of everything that scrolls — tagline plus overview. Zero
' while the labels are still rendering; their renderTracking observers bring us
' back. Measured on scrollContent rather than the labels so the LayoutGroup's
' own spacing is included — but GUARDED per label, because the group reports a
' non-zero height as soon as EITHER child has rendered. Measuring the group
' alone let a tagline-plus-overview dialog settle on a height short by the
' whole tagline, and nothing downstream could tell that apart from a real one.
contentHeight = 0
if hasBodyText()
if not bodyLabelsMeasured() then return
contentHeight = m.scrollContent.localBoundingRect().height
if contentHeight = 0 then return
end if
layout = computeDialogLayout({
panelWidth: DIALOG_WIDTH,
titleHeight: titleHeight,
bodyHeight: contentHeight,
footerHeight: footerHeight
})
m.chrome.layout = layout
m.textAreaHeight = layout.body.height
m.contentHeight = contentHeight
m.textClip.translation = [layout.body.x, layout.body.y]
m.textClip.clippingRect = [0, 0, m.textAreaWidth, m.textAreaHeight]
scrollTrackX = layout.body.x + layout.body.width - SCROLLBAR_THUMB_WIDTH
m.scrollTrack.translation = [scrollTrackX + SCROLLBAR_TRACK_INSET, layout.body.y]
m.scrollTrack.height = m.textAreaHeight
m.scrollThumb.translation = [scrollTrackX, layout.body.y]
m.okButton.translation = [centreInRect(layout.footer, DIALOG_BUTTON_MIN_WIDTH), layout.footer.y]
applyScrollBounds()
end sub
' Rendered height of the OK button, or the shared fallback before it sizes
' itself. The fallback and the reasoning for it live in dialogLayout.bs, where
' JRDialog's row and QuickConnectDialog's row read the same one.
function okButtonHeight() as integer
if isValid(m.okButton)
rect = m.okButton.localBoundingRect()
if rect.height > 0 then return rect.height
end if
return dialogButtonFallbackHeight(m.global.constants.fontSizeMedium)
end function
' True when this dialog has anything to show in its scrolling body. Used to tell
' "nothing to render" apart from "not rendered yet", which otherwise both read as
' a measured height of zero and would park applyLayout forever.
function hasBodyText() as boolean
if isValidAndNotEmpty(m.top.sections) then return true
return isValidAndNotEmpty(m.top.overview) or isValidAndNotEmpty(m.top.tagline)
end function
' True once EVERY label this dialog expects to show reports a rendered height.
' Each label carries its own renderTracking observer, so each brings us back at
' least once — this only decides whether the pass we are in may be trusted, not
' whether another one is coming.
function bodyLabelsMeasured() as boolean
' Structured body: the same rule as the string body, generalised from two named
' labels to however many rows were built. The group reports a non-zero height as
' soon as ANY child has rendered, so trusting the group alone would settle the
' panel on a fraction of the report.
if isValidAndNotEmpty(m.top.sections)
for each node in m.reportNodes
if node.localBoundingRect().height = 0 then return false
end for
return m.reportNodes.count() > 0
end if
if isValidAndNotEmpty(m.top.tagline)
if not isValid(m.taglineText) then return false
if m.taglineText.localBoundingRect().height = 0 then return false
end if
if isValidAndNotEmpty(m.top.overview)
if m.overviewText.localBoundingRect().height = 0 then return false
end if
return true
end function
' Derive the scroll state from the two heights the layout just settled. The body
' is clamped at PANEL_MAX_HEIGHT by computeDialogLayout, so `maxScroll > 0` is
' exactly "the content did not fit" — this dialog never re-derives that ceiling.
sub applyScrollBounds()
m.maxScroll = m.contentHeight - m.textAreaHeight
if m.maxScroll < 0 then m.maxScroll = 0
scrolls = m.maxScroll > 0
m.scrollTrack.visible = scrolls
m.scrollThumb.visible = scrolls
if scrolls
thumbRatio = m.textAreaHeight / m.contentHeight
m.scrollThumb.height = m.textAreaHeight * thumbRatio
else
' A panel that grew, or content that shrank, can leave a stale offset behind
' and scroll text out of a viewport that no longer scrolls.
m.scrollPosition = 0
m.scrollContent.translation = [0, 0]
end if
if m.scrollPosition > m.maxScroll
m.scrollPosition = m.maxScroll
m.scrollContent.translation = [0, -m.scrollPosition]
end if
updateScrollThumb()
' Place the opening focus on EVERY pass until the user takes over, not once.
'
' Once was the obvious shape and it was wrong, because the first completed pass
' is not the one that knows anything. `init()` runs a layout before a single
' field is set, so a latch armed there sent every scrolling dialog to the OK
' button — where `down` is swallowed (see onKeyEvent) and the user has to press
' UP to reach text they are looking straight at. Confirmed on a Stick 4K: the
' OK button held focus immediately after createObject, with no content.
'
' Deferring the latch to the first pass WITH content is not enough either. A
' wrapped label reports a partial height before it finishes, so a pass can
' carry real-but-short content and derive `scrolls` from it; measured on the
' same device, the panel settled at 450px for a body that wanted far more.
' Anything that latches on a single pass is betting on which pass it is in.
'
' So: re-derive it every time, and stop only when the answer stops being ours
' to give. That is what the old calculateScrollBounds did — it re-placed focus
' on every recalculation — plus the guard it lacked, which is the real concern
' behind the latch: a late pass must not yank focus from a user who has already
' scrolled. setFocus on the already-focused node is a no-op, so the repeats
' cost nothing.
' hasBodyText() is the second half of the same rule, and it is load-bearing:
' init() runs a layout BEFORE a single field is set, and a body of nothing
' cannot scroll — so that pass derives `scrolls = false` and, without this
' guard, focused the OK button on the strength of it. The focus then moved to
' the scroll area on the first pass that had real content, which is correct,
' but the BUTTON had already painted itself focused and never repainted:
' TextButton applies its focus colours when it gains focus and reverts them on
' a focusedChild change that this sequence never delivers. Net effect, measured
' on a Stick 4K: the scroll area held focus while the OK button rendered a
' `focusBorder` ring, so the dialog opened looking like two things were focused
' at once and read as unresponsive.
'
' Everything above re-derives happily from a contentless pass; only the focus
' placement must not. applyLayout guarantees the body has been MEASURED
' whenever hasBodyText() is true, so this is exactly "wait for a pass that
' knows something".
if not m.userHasNavigated and hasBodyText()
if scrolls
m.textClip.setFocus(true)
else
m.okButton.setFocus(true)
end if
end if
updateScrollbarFocus(m.textClip.hasFocus())
end sub
sub onTitleChanged()
m.chrome.title = m.top.title
end sub
sub onOverviewChanged()
m.overviewText.text = m.top.overview
' Re-attach if a structured body had taken it out of the flow.
if not isValid(m.overviewText.getParent()) then m.scrollContent.appendChild(m.overviewText)
end sub
' Dynamically creates or removes the tagline label based on tagline text
sub onTaglineChanged()
tagline = m.top.tagline
if isValidAndNotEmpty(tagline)
if not isValid(m.taglineText)
' Create tagline label dynamically and insert before overview text
m.taglineText = CreateObject("roSGNode", "LabelPrimaryMedium")
m.taglineText.isBold = true
m.taglineText.wrap = true
m.taglineText.width = m.textAreaWidth
m.taglineText.maxLines = 500
m.taglineText.enableRenderTracking = true
m.taglineText.observeField("renderTracking", "onTaglineRendered")
m.scrollContent.insertChild(m.taglineText, 0)
end if
m.taglineText.text = tagline
else
if isValid(m.taglineText)
m.scrollContent.removeChild(m.taglineText)
m.taglineText = invalid
' The body just got shorter, so the panel is now too tall for it.
applyLayout()
end if
end if
end sub
' The structured body arrived, or was refreshed.
'
' Two paths, and choosing between them is the whole reason this dialog can carry a
' LIVE report. When the row ids are unchanged — the ordinary case, because a
' refresh re-reads the same stream — only the text of the value labels is
' rewritten. No node is created or destroyed, the body's height does not move, and
' neither does the user's scroll position or focus. When the shape genuinely
' differs (a transcode started, a section appeared), everything is rebuilt and the
' panel re-flows.
'
' The in-place path deliberately does NOT call applyLayout(). A refreshed figure
' occupies the same single line as the one it replaces, so re-flowing would be a
' no-op that risks yanking the panel out from under someone mid-read.
sub onSectionsChanged()
sections = m.top.sections
if not isValidAndNotEmpty(sections)
clearReportNodes()
applyLayout()
return
end if
if reportShapeMatches(sections)
updateReportValues(sections)
return
end if
buildReportNodes(sections)
applyLayout()
end sub
' True when the incoming sections carry exactly the row ids already on screen, in
' the same order. Order is part of it: two reports with the same ids in a
' different order are a different document, and reusing nodes across that would
' silently reorder the labels.
function reportShapeMatches(sections as object) as boolean
if m.reportRowOrder.count() = 0 then return false
index = 0
for each section in sections
if not isValid(section) or not isValid(section.rows) then return false
for each row in section.rows
if index >= m.reportRowOrder.count() then return false
if m.reportRowOrder[index] <> row.id then return false
index++
end for
end for
return index = m.reportRowOrder.count()
end function
' Rewrite the values whose text actually changed. Skipping the unchanged ones is
' not just a saving: assigning to a Label's text re-renders it, and doing that to
' every row five seconds apart would repaint the whole report to update two
' numbers.
sub updateReportValues(sections as object)
for each section in sections
if not isValid(section) or not isValid(section.rows) then continue for
for each row in section.rows
node = m.reportValueNodes[row.id]
if isValid(node) and node.text <> row.value then node.text = row.value
end for
end for
end sub
sub clearReportNodes()
for each node in m.reportNodes
node.unobserveField("renderTracking")
end for
m.reportNodes = []
m.reportValueNodes = {}
m.reportRowOrder = []
' Remove every child this dialog added, leaving the XML-declared overviewText
' (and any tagline) alone — the same node can serve a plain description later.
for each child in m.scrollContent.getChildren(-1, 0)
if child.id = "reportSection" then m.scrollContent.removeChild(child)
end for
' Put the spacing back too. buildReportNodes widens it for section blocks; a
' dialog reused for a plain description afterwards would otherwise keep the
' report's gaps between a tagline and a paragraph that are not sections.
m.scrollContent.itemSpacings = [DEFAULT_BODY_SPACING]
end sub
sub buildReportNodes(sections as object)
clearReportNodes()
' REMOVED from the group, not hidden. The XML-declared paragraph label is unused
' in this mode, and a LayoutGroup positions every child by its bounding rect —
' visibility is not part of that, so an empty hidden Label still reserved a line
' and left a visible gap under the status. Verified on a Stick 4K: setting
' visible = false changed nothing. onOverviewChanged puts it back if a plain
' description is ever set on this same dialog.
if isValid(m.overviewText.getParent()) then m.scrollContent.removeChild(m.overviewText)
' Sections are spaced further apart than the rows inside them; the bold heading
' plus that gap is what separates one block from the next without a rule.
m.scrollContent.itemSpacings = [REPORT_SECTION_SPACING]
for each section in sections
if not isValid(section) or not isValidAndNotEmpty(section.rows) then continue for
group = CreateObject("roSGNode", "LayoutGroup")
group.id = "reportSection"
group.layoutDirection = "vert"
group.itemSpacings = [REPORT_ROW_SPACING]
if isValidAndNotEmpty(section.heading)
heading = CreateObject("roSGNode", "LabelPrimaryMedium")
heading.isBold = true
heading.text = section.heading
heading.width = m.textAreaWidth
group.appendChild(heading)
end if
labelWidth = REPORT_LABEL_WIDTH
if isValid(section.wideLabels) and section.wideLabels = true then labelWidth = REPORT_WIDE_LABEL_WIDTH
for each row in section.rows
group.appendChild(buildReportRow(row, labelWidth))
end for
m.scrollContent.appendChild(group)
end for
end sub
' One label/value row.
'
' The label column is LabelSecondary and the value LabelPrimary, so the eye lands
' on the data rather than on the field names — the same emphasis split the theme
' table describes for text. The value wraps (a file path needs two or three
' lines); the label never does, which is what keeps the columns aligned.
function buildReportRow(row as object, labelWidth as integer) as object
rowGroup = CreateObject("roSGNode", "LayoutGroup")
rowGroup.layoutDirection = "horiz"
rowGroup.itemSpacings = [0]
' ONE LINE, and deliberately NOT render-tracked — see REPORT_LABEL_WIDTH for why
' the column is wide enough that it does not need to wrap.
label = CreateObject("roSGNode", "LabelSecondaryMedium")
label.text = row.label
label.width = labelWidth
label.maxLines = 1
rowGroup.appendChild(label)
value = CreateObject("roSGNode", "LabelPrimaryMedium")
value.text = row.value
value.width = m.textAreaWidth - labelWidth
value.wrap = true
value.maxLines = 6
trackReportNode(value)
rowGroup.appendChild(value)
m.reportValueNodes[row.id] = value
m.reportRowOrder.push(row.id)
return rowGroup
end function
' Every label whose rendered height feeds the panel gets a renderTracking
' observer, for the same reason the overview label has one: the panel cannot be
' sized until they have all reported. Only WRAPPING labels are tracked — a
' single-line label column cannot change the row height, and each tracked node is
' expensive twice over: it adds a settle signal AND a member to the set every
' other settle signal re-measures. Measured on a Stick 4K, one
' localBoundingRect() read costs 23.05 µs, so that set is quadratic in real time —
' 26 tracked nodes is ~15.6 ms of render-thread work per open and 46 would be
' ~48.8 ms. Do not track a node whose height cannot vary.
sub trackReportNode(node as object)
node.enableRenderTracking = true
node.observeField("renderTracking", "onTextRendered")
m.reportNodes.push(node)
end sub
' Tagline has rendered — its height is part of the body, so the PANEL changes,
' not just the scroll bounds.
sub onTaglineRendered()
applyLayout()
end sub
' Once the text has rendered its height is known, which is the last input the
' panel height was waiting on.
sub onTextRendered()
' Don't check for "full" - the observer may not fire again after "partial".
' applyLayout's own measured-height guards handle unrendered text.
applyLayout()
end sub
' Updates the scroll thumb position to reflect the current scroll position
sub updateScrollThumb()
if m.maxScroll <= 0 then return
scrollRatio = m.scrollPosition / m.maxScroll
trackRange = m.textAreaHeight - m.scrollThumb.height
thumbY = m.scrollTrack.translation[1] + (trackRange * scrollRatio)
m.scrollThumb.translation = [m.scrollThumb.translation[0], thumbY]
end sub
sub onFocusChanged()
updateScrollbarFocus(m.textClip.hasFocus())
if not m.hasNarrated
m.narrationTimer.control = "start"
return
end if
' Afterwards only the OK button needs announcing — moving between it and the
' scroll area is otherwise silent.
if m.okButton.hasFocus() then narrateFocusedElement(toString(m.okButton.text))
end sub
' Announce the dialog once the platform's own focus announcement is out of the
' way. The overview text IS the announcement here: this dialog exists to be read,
' so a screen-reader user opening it wants its contents, not just its title.
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
body = []
if isValidAndNotEmpty(m.top.tagline) then body.push(m.top.tagline)
if isValidAndNotEmpty(m.top.overview) then body.push(m.top.overview)
body.append(reportNarrationLines())
focusedText = ""
if m.okButton.hasFocus() then focusedText = toString(m.okButton.text)
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
' The structured body, spoken.
'
' Without this a report dialog announces its title and then falls silent, because
' the opening announcement reads `overview` and a report sets `sections` instead.
' Each row is read as "label, value" so the pairing survives being spoken — the
' two-column alignment that carries it visually is worth nothing to a listener.
function reportNarrationLines() as object
lines = []
if not isValidAndNotEmpty(m.top.sections) then return lines
for each section in m.top.sections
if not isValid(section) or not isValidAndNotEmpty(section.rows) then continue for
if isValidAndNotEmpty(section.heading) then lines.push(section.heading)
for each row in section.rows
if isValidAndNotEmpty(row.value)
if isValidAndNotEmpty(row.label)
lines.push(row.label + ", " + row.value)
else
lines.push(row.value)
end if
end if
end for
end for
return lines
end function
' Updates scrollbar thumb color based on focus state
sub updateScrollbarFocus(isFocused as boolean)
constants = m.global.constants
if isFocused
m.scrollThumb.color = constants.colorPrimary
else
m.scrollThumb.color = constants.colorBackgroundSecondary
end if
end sub
' Cancel this dialog from OUTSIDE — see cancelOpenDialog() in source/utils/dialogs.bs.
' This dialog is read-only and has no `result` field, so there is nothing to
' deliver: cancelling it is exactly closing it.
sub cancelDialog()
closeDialog()
end sub
' Dismiss this overlay. It is appended directly to the scene by FocusableOverview, so closing
' is a removeChild from our parent + restoring focus to the element that opened us
' (returnFocusTo).
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)
' LAST, so an observer sees a dialog that is already detached and has already
' handed focus back. cancelDialog routes through here too, so this is the one
' place both close routes pass.
m.top.closed = true
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
if key = "back"
closeDialog()
return true
end if
' The three keys this dialog acts on all either scroll or move focus, so from
' the first one the placement is the USER's and a late layout pass must not
' redo it (see applyScrollBounds). The ones that look like no-ops — `down` on
' the OK button, `up` at the top of the scroll — count too: the user is still
' saying where they are. Keys we do NOT handle deliberately do not count, or a
' stray `right` before the text renders would freeze the opening placement.
if key = "OK" or key = "down" or key = "up" then m.userHasNavigated = true
if key = "OK"
if m.okButton.hasFocus()
closeDialog()
return true
end if
m.okButton.setFocus(true)
updateScrollbarFocus(false)
return true
end if
if key = "down"
if m.okButton.hasFocus() then return true
if m.maxScroll > 0 and m.scrollPosition < m.maxScroll
m.scrollPosition = m.scrollPosition + SCROLL_STEP
if m.scrollPosition > m.maxScroll
m.scrollPosition = m.maxScroll
end if
m.scrollContent.translation = [0, -m.scrollPosition]
updateScrollThumb()
return true
end if
m.okButton.setFocus(true)
updateScrollbarFocus(false)
return true
end if
if key = "up"
if m.okButton.hasFocus()
' Only move back to text area if there's scrollable content
if m.maxScroll > 0
m.textClip.setFocus(true)
updateScrollbarFocus(true)
end if
return true
end if
if m.scrollPosition > 0
m.scrollPosition = m.scrollPosition - SCROLL_STEP
if m.scrollPosition < 0
m.scrollPosition = 0
end if
m.scrollContent.translation = [0, -m.scrollPosition]
updateScrollThumb()
return true
end if
' At top of scroll, consume event to prevent bubbling
return true
end if
return false
end function