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