components_Buttons_JRButtons.bs
' bsc-disable-file print-locations — legacy print() sites; migration to m.log.* tracked by tech-debt.md#legacy-print-statements
import "pkg:/source/utils/misc.bs"
import "pkg:/source/utils/tasks.bs"
' TextSizeTask measures at this size and showButtons renders at it. They must agree or the
' labels are laid out to the wrong widths, so it is one constant rather than two literals.
const BUTTON_FONT_SIZE = 40
sub init()
m.top.focusable = true
m.menubg = m.top.findNode("menubg")
m.focusRing = m.top.findNode("focus")
m.buttonGroup = m.top.findNode("buttonGroup")
m.focusAnim = m.top.findNode("moveFocusAnimation")
m.focusAnimTranslation = m.top.findNode("focusLocation")
m.focusAnimWidth = m.top.findNode("focusWidth")
m.focusAnimHeight = m.top.findNode("focusHeight")
applyTheme()
m.buttonCount = 0
m.selectedFocusedIndex = 0
m.top.observeField("focusedChild", "focusChanged")
m.top.enableRenderTracking = true
m.top.observeField("renderTracking", "renderChanged")
end sub
sub applyTheme()
constants = m.global.constants
m.menubg.blendColor = constants.colorBackgroundPrimary
m.focusRing.color = constants.colorPrimary
end sub
'
' When Selected Index set, ensure it is the one Focused
sub onSelectedIndexChanged()
m.selectedFocusedIndex = m.top.selectedIndex
end sub
'
' When options are fully displayed, set focus and selected option
sub renderChanged()
if m.top.renderTracking = "full"
highlightSelected(m.selectedFocusedIndex, false)
m.top.setfocus(true)
end if
end sub
' `buttons` onChange. The measurement has to happen on a Task: it needs roFontRegistry, which
' Roku refuses to create on the render thread ("MAIN|TASK-only component"). The task is built
' per write and released in showButtons, so nothing is retained between measurements.
sub updateButtons()
cancelTextSizeTask()
m.textSizeTask = createObject("roSGNode", "TextSizeTask")
m.textSizeTask.observeField("width", "showButtons")
m.textSizeTask.fontsize = BUTTON_FONT_SIZE
m.textSizeTask.text = m.top.buttons
launchTask(m.textSizeTask)
end sub
' Stop, unobserve, and drop the in-flight TextSizeTask if one exists. Safe to call when none is
' active. The single teardown path for m.textSizeTask, mirroring QueueManager's cancelActive*.
' JRButtons extends Group, so no onDestroy lifecycle hook runs on it — releasing the node as
' soon as its one-shot measurement lands is what keeps it from outliving the screen.
sub cancelTextSizeTask()
if isValid(m.textSizeTask)
m.textSizeTask.unobserveField("width")
m.textSizeTask.control = "STOP"
m.textSizeTask = invalid
end if
end sub
' Rebuilds the label row. Clears first so a second `buttons` write replaces the row instead of
' appending a second copy of it — createChild appends, and buttonCount drives key nav.
sub showButtons()
widths = m.textSizeTask.width
lineHeight = m.textSizeTask.height
m.buttonGroup.removeChildrenIndex(m.buttonGroup.getChildCount(), 0)
m.buttonCount = 0
totalWidth = 110 ' track for menu background width - start with side padding
for i = 0 to m.top.buttons.count() - 1
m.buttonCount = m.buttonCount + 1
l = m.buttonGroup.createChild("Label")
l.text = m.top.buttons[i]
l.font.size = BUTTON_FONT_SIZE
' Apply fallback font scaling if enabled
if m.global.user.settings.uiFontFallback = true
l.font.uri = "tmp:/font"
applyFallbackFontScale(l)
end if
l.translation = [0, 10]
l.height = lineHeight
l.width = widths[i] + 50
l.horizAlign = "center"
l.vertAlign = "center"
totalWidth = totalWidth + l.width + 45
end for
m.menubg.width = totalWidth
m.menubg.height = lineHeight + 40
' Released only once the row is built. `widths` was read out of the task node, and Roku does
' not document whether an array field read yields a copy or a view into the node's storage —
' keeping the node alive across the loop means we never have to depend on the answer.
cancelTextSizeTask()
end sub
sub applyFallbackFontScale(labelNode as object)
currentSize = labelNode.font.size
if currentSize <= 0
print "ERROR - JRButtons applyFallbackFontScale: Invalid font size"
return
end if
' Apply the global scale factor
fontScaleFactor = m.global.user.fontScaleFactor
if isValid(fontScaleFactor) and fontScaleFactor > 0
scaledSize = currentSize * fontScaleFactor
labelNode.font.size = scaledSize
else
print "ERROR - JRButtons applyFallbackFontScale: Invalid font scale factor"
return
end if
end sub
' Highlight selected menu option
sub highlightSelected(index as integer, animate = true)
val = m.buttonGroup.getChild(index)
if not isValid(val) then return
rect = val.ancestorBoundingRect(m.top)
if animate = true
m.focusAnimTranslation.keyValue = [m.focusRing.translation, [rect.x - 25, rect.y - 30]]
m.focusAnimWidth.keyValue = [m.focusRing.width, val.width + 50]
m.focusAnimHeight.keyValue = [m.focusRing.height, val.height + 60]
m.focusAnim.control = "start"
else
m.focusRing.translation = [rect.x - 25, rect.y - 30]
m.focusRing.width = val.width + 50
m.focusRing.height = val.height + 60
end if
end sub
' Change opacity of the highlighted menu item based on focus
sub focusChanged()
if m.top.isInFocusChain()
m.focusRing.opacity = 1
else
m.focusRing.opacity = 0.6
end if
end sub
function onKeyEvent(key as string, press as boolean) as boolean
if not press then return false
if key = "left"
if m.selectedFocusedIndex > 0 then m.selectedFocusedIndex = m.selectedFocusedIndex - 1
highlightSelected(m.selectedFocusedIndex)
m.top.focusedIndex = m.selectedFocusedIndex
return true
else if key = "right"
if m.selectedFocusedIndex < m.buttonCount - 1 then m.selectedFocusedIndex = m.selectedFocusedIndex + 1
highlightSelected(m.selectedFocusedIndex)
m.top.focusedIndex = m.selectedFocusedIndex
return true
else if key = "OK"
m.top.selectedIndex = m.selectedFocusedIndex
return true
end if
return false
end function