components_ui_button_TextButton.bs

import "pkg:/source/utils/misc.bs"

' Selected-state underline. 6px is the same weight the overhang has always used
' for the selected tab, so the signal reads the same wherever it appears.
'
' The bar is CENTERED in the gap between the label's bottom edge and the button's
' bottom edge rather than sitting at a fixed offset, and that is the whole reason
' this lives in one place now. JRTab's original version hard-coded `+12` below a
' padding-derived text bottom, correct only at JRTab's padding of 30 and only
' while no minHeight is in play. Hand-copied into the subtitle panel at padding 24
' it drew the bar 6px from the button's bottom edge and read as colliding with it.
' Centering makes the geometry correct at any padding and any minHeight, and
' reproduces JRTab's existing pixels exactly at padding 30.
const TEXT_BUTTON_INDICATOR_HEIGHT = 6
' Affordance icon, sized 1:1 with the source bitmaps exactly as TrackDropdown does
' with the same 24x24 chevron, so the two dropdown affordances match.
const TEXT_BUTTON_ICON_SIZE = 24
' Gap between the label's right edge and the icon.
const TEXT_BUTTON_ICON_GUTTER = 12
' The in-flight spinner takes the icon slot (same size, same position, same
' gutter) rather than having geometry of its own. That is why there is no
' separate spinner-size constant: a leading spinner beside the label is the same
' shape as a leading icon beside the label, so making it literally the same slot
' means one set of arithmetic, and a button that carries a real icon swaps
' one for the other with no resize at all.
'
' It replaced a 40px spinner drawn OVER the hidden label. That was worse on both
' counts it was meant to serve: losing the label cost the button its identity at
' the exact moment the user is asking what it is doing, and a size picked for the
' button rather than for the icon row made the busy state louder than the control.

sub init()
  m.buttonBackground = m.top.findNode("buttonBackground")
  m.buttonBorder = m.top.findNode("buttonBorder")
  m.buttonText = m.top.findNode("buttonText")
  m.buttonIcon = m.top.findNode("buttonIcon")
  m.buttonIndicator = m.top.findNode("buttonIndicator")
  m.buttonSpinner = m.top.findNode("buttonSpinner")
  ' Size the spinner's poster HERE, not when it starts spinning.
  '
  ' Spinner.init() sets a uri and no dimensions, so the poster draws at the
  ' asset's native size: spinner_fhd.png is 125x125, inside a button 70.4px
  ' tall. Sizing it lazily left every TextButton carrying a 125px child while
  ' idle, and layoutIcon then moved that child to the icon slot for any button
  ' that HAS an icon: the subtitle panel's language button (a right-side chevron)
  ' ended up with a bounding rect ~100px wider than its own background, so the
  ' LayoutGroup holding the control row pushed the next button across and the
  ' three buttons stopped looking evenly spaced. Buttons without an icon were
  ' unaffected, which is what made it read as inconsistent rather than as a
  ' uniform shift.
  '
  ' The load-time trio for the same reason applyIconState carries it: 125x125 of
  ' texture to draw 24 is waste, and loadWidth/loadHeight do nothing on their own.
  m.buttonSpinner.poster.width = TEXT_BUTTON_ICON_SIZE
  m.buttonSpinner.poster.height = TEXT_BUTTON_ICON_SIZE
  m.buttonSpinner.poster.loadWidth = TEXT_BUTTON_ICON_SIZE
  m.buttonSpinner.poster.loadHeight = TEXT_BUTTON_ICON_SIZE
  m.buttonSpinner.poster.loadDisplayMode = "limitSize"

  m.top.observeField("isEnabled", "onEnabledChanged")
  m.top.observeField("background", "onBackgroundChanged")
  m.top.observeField("border", "onBorderChanged")
  m.top.observeField("textColor", "onTextColorChanged")
  m.top.observeField("text", "onTextChanged")
  m.top.observeField("padding", "onPaddingChanged")
  m.top.observeField("focusedChild", "onFocusChanged")
  m.top.observeField("minWidth", "onSizeChanged")
  m.top.observeField("minHeight", "onSizeChanged")
  m.top.observeField("icon", "onIconChanged")
  m.top.observeField("iconSide", "onSizeChanged")
  m.top.observeField("showSelectedIndicator", "onSizeChanged")
  ' Named distinctly from JRTab's own isButtonSelected observer so a subclass can
  ' still react to the same field without displacing the indicator's own toggle.
  m.top.observeField("isButtonSelected", "onSelectedIndicatorChanged")

  ' Enable render tracking on parent to know when component has rendered
  m.top.enableRenderTracking = true
  m.top.observeField("renderTracking", "onRenderComplete")

  applyTheme()

  ' An icon set as an XML attribute lands before init() runs, so the observer
  ' above never sees it. Apply whatever is already there.
  applyIconState()
end sub

sub applyTheme()
  constants = m.global.constants

  m.buttonIndicator.color = constants.colorSecondary

  ' Set default colors
  m.top.background = constants.colorBackgroundSecondary
  m.top.border = constants.colorBackgroundSecondary
  m.top.textColor = constants.colorTextPrimary
  m.top.focusBorder = constants.colorPrimary
  m.top.focusBackground = constants.colorBackgroundSecondary
  m.top.textFocusColor = constants.colorTextPrimary
end sub

sub onFocusChanged()
  if m.top.hasFocus()
    if m.top.enableBorder
      m.buttonBorder.blendColor = m.top.focusBorder
    else
      m.buttonBorder.blendColor = m.top.background
    end if
    m.buttonBackground.blendColor = m.top.focusBackground
    ' A DISABLED button keeps its disabled text colour even while focused.
    '
    ' textFocusColor is full-strength by definition, so applying it here made a
    ' focused disabled button indistinguishable from an available one. That went
    ' unnoticed because JRButtonGroup skips disabled buttons when it moves focus,
    ' so until a screen hand-rolled its own focus (the subtitle panel) a disabled
    ' TextButton could never hold focus in the first place. The focus BORDER is
    ' still drawn on purpose: the button is where the remote is pointing, and
    ' hiding that would lose the user their place.
    '
    ' `dynamic`, and that is not laziness. These are SceneGraph `color` fields:
    ' they are WRITTEN as the "#RRGGBBAA" strings the theme holds and READ BACK as
    ' roInt, so a local that carries one can be either. Declaring it `as string`
    ' crashed every TextButton at init with ERR_TM the moment applyTheme() wrote a
    ' color, and neither bsc nor the unit suite can see it, because the type only
    ' becomes an integer on a real node on a real device.
    labelColor as dynamic = m.top.textFocusColor
    if not m.top.isEnabled then labelColor = m.top.textColor
  else
    if m.top.enableBorder
      m.buttonBorder.blendColor = m.top.border
    else
      m.buttonBorder.blendColor = m.top.background
    end if
    m.buttonBackground.blendColor = m.top.background
    labelColor = m.top.textColor
  end if

  m.buttonText.color = labelColor
  ' The icon and the spinner are one slot and must read as part of the label, so
  ' both take its color. Tinting only whichever is currently visible leaves the
  ' other stale for the frame it is switched on. Taken from the local rather than
  ' read back off m.buttonText.color, so the value stays the one we decided above.
  m.buttonIcon.blendColor = labelColor
  m.buttonSpinner.poster.blendColor = labelColor
end sub

sub onBackgroundChanged()
  m.buttonBackground.blendColor = m.top.background
  if not m.top.hasFocus()
    if m.top.enableBorder
      m.buttonBorder.blendColor = m.top.border
    else
      m.buttonBorder.blendColor = m.top.background
    end if
  end if
end sub

sub onBorderChanged()
  if not m.top.hasFocus() and m.top.enableBorder
    m.buttonBorder.blendColor = m.top.border
  end if
end sub

sub onTextColorChanged()
  ' Route through onFocusChanged rather than setting the label directly: a
  ' disabled button now resolves its colour differently depending on focus, and
  ' that resolution lives in one place. onEnabledChanged writes textColor, so
  ' this is also the path that repaints a button as it is disabled or re-enabled.
  onFocusChanged()
end sub

sub onTextChanged()
  m.buttonText.text = m.top.text
  ' If already rendered, resize immediately. Otherwise wait for onRenderComplete
  if m.top.renderTracking = "full"
    setSizeAndCenterText()
  end if
end sub

' Called when the component has rendered and we can measure the label accurately
sub onRenderComplete()
  if m.top.renderTracking <> "full" then return
  setSizeAndCenterText()
end sub

sub setSizeAndCenterText()
  if not isValid(m.buttonText) then return
  if m.buttonText.text.Len() = 0 then return

  ' Reset label size to allow accurate measurement of text content
  m.buttonText.width = 0
  m.buttonText.height = 0

  ' Get accurate text dimensions from rendered label
  boundingRect = m.buttonText.localBoundingRect()
  textWidth = boundingRect.width

  ' If not rendered yet, wait for onRenderComplete
  if textWidth = 0 then return

  ' Calculate visible text height for equal padding
  ' localBoundingRect height includes line spacing, adjust to match horizontal padding
  fontSize = m.buttonText.font.size
  visibleTextHeight = fontSize * 0.7

  ' An icon reserves its own width plus a gutter, so the label gets what's left
  ' rather than overlapping it. The in-flight spinner reserves the SAME slot, so
  ' a button that is loading is laid out as though it had an icon. That is what
  ' keeps the spinner beside the label instead of on top of it, and what makes a
  ' button that already has an icon cost nothing extra to put into a busy state.
  iconSpace = 0
  if hasIconSlot()
    iconSpace = TEXT_BUTTON_ICON_SIZE + TEXT_BUTTON_ICON_GUTTER
  end if

  ' Calculate button dimensions with padding
  buttonWidth = textWidth + iconSpace + (m.top.padding * 2)
  buttonHeight = visibleTextHeight + (m.top.padding * 2)

  ' Ensure minimum dimensions (this is the only place minWidth should be enforced)
  if buttonWidth < m.top.minWidth
    buttonWidth = m.top.minWidth
  end if
  if buttonHeight < m.top.minHeight
    buttonHeight = m.top.minHeight
  end if

  ' Set background size
  m.buttonBackground.width = buttonWidth
  m.buttonBackground.height = buttonHeight

  m.buttonText.height = buttonHeight
  ' The label is horizAlign="center", so its text lands in the middle of whatever
  ' box we give it. Shift the BOX, never the node's origin.
  '
  ' This used to be a full-width label translated by textCenterOffset(), which
  ' is NEGATIVE for a trailing icon, so the label hung outside the button's own
  ' left edge and the node's boundingRect started at -18. That is invisible until
  ' the button is inside a LayoutGroup, which aligns children by boundingRect and
  ' therefore shoved the whole button 18px right to bring the overhang back to
  ' zero: measured on device 2026-09-14, the subtitle panel's language button sat at x=114
  ' while every heading in the same column sat at 96. Narrowing the box instead
  ' puts the text in exactly the same place (see the identity below) while
  ' keeping every child inside the background, so boundingRect == the button.
  '
  '   trailing icon: 0        + (bw - iconSpace)/2 = bw/2 - iconSpace/2
  '   leading icon:  iconSpace + (bw - iconSpace)/2 = bw/2 + iconSpace/2
  '   no icon:       0        + bw/2               = bw/2
  '
  ' That is bw/2 + textCenterOffset(iconSpace) in all three cases, so the icon
  ' and underline arithmetic below still reads the same centre it always did.
  m.buttonText.width = buttonWidth - iconSpace
  m.buttonText.translation = [labelLeft(iconSpace), 0]

  layoutIcon(buttonWidth, buttonHeight, textWidth, iconSpace)
  ' The underline has to be derived from where the text ACTUALLY landed, not
  ' assumed to start at `padding`: that only holds with no icon and no binding
  ' minWidth. Getting this wrong is the bug that motivated centralising it here.
  textLeft = int((buttonWidth / 2) + textCenterOffset(iconSpace) - (textWidth / 2))
  layoutSelectedIndicator(buttonHeight, textLeft, textWidth, visibleTextHeight)

  setFocusBorderSize()

  m.buttonBackground.visible = true
  m.buttonBorder.visible = true
  m.buttonText.visible = true
  ' After layoutIcon, which is what gave the spinner its position.
  applyLoadingState()

  ' Apply colors based on current focus state (focus may have been set before render)
  onFocusChanged()

  ' Signal that the button is ready (sized and visible)
  m.top.isReady = true
end sub

' ── Icon ─────────────────────────────────────────────────────────────────────

sub onIconChanged()
  applyIconState()
  setSizeAndCenterText()
end sub

' applyIconState: URI, size and visibility only. Placement needs the button's
' measured size and therefore belongs to setSizeAndCenterText.
sub applyIconState()
  ' Synchronous load for what is always a local pkg:/images asset, set before uri
  ' so it governs that load. Poster.loadSync defaults to false (async, and per
  ' the Roku docs "may not appear immediately"), which for a button icon means the
  ' chevron arrives a frame or more after the button it belongs to, and the label
  ' beside it re-centres when it does, because a Poster with no bitmap yet
  ' contributes nothing to the layout.
  m.buttonIcon.loadSync = true
  m.buttonIcon.uri = m.top.icon
  m.buttonIcon.width = TEXT_BUTTON_ICON_SIZE
  m.buttonIcon.height = TEXT_BUTTON_ICON_SIZE
  ' loadWidth/loadHeight cap the texture, loadDisplayMode is what makes the cap
  ' bite: its default of noScale loads "at the image's original resolution", so
  ' the two size fields were inert here on their own and every icon handed to a
  ' TextButton was loaded at whatever the asset happened to be. limitSize is the
  ' mode the app's other small local icons already use (CommunityRating,
  ' CriticRating, Toast): aspect ratio preserved, small images left alone.
  m.buttonIcon.loadWidth = TEXT_BUTTON_ICON_SIZE
  m.buttonIcon.loadHeight = TEXT_BUTTON_ICON_SIZE
  m.buttonIcon.loadDisplayMode = "limitSize"
  m.buttonIcon.visible = isValidAndNotEmpty(m.top.icon) and not m.top.isLoading
end sub

' hasIconSlot: Whether this button reserves the leading/trailing affordance slot.
'
' An icon and the in-flight spinner share it, so the question sizing asks is "is
' anything in the slot", not "is there an icon". Keeping it one predicate is what
' guarantees the width the label is measured against and the width the spinner is
' placed in can never disagree.
function hasIconSlot() as boolean
  return m.top.isLoading or isValidAndNotEmpty(m.top.icon)
end function

' textCenterOffset: How far the label's centre shifts from the button's centre.
'
' A LEADING icon is part of the label ("▶ Play" is one phrase), so the pair is
' centred as a unit and the text gives up half the icon's space to its left.
' A TRAILING icon is a disclosure affordance belonging to the button rather than
' the phrase, so it is pinned to the right edge and the text centres in what's
' left, shifting the same distance the other way.
function textCenterOffset(iconSpace as float) as float
  if iconSpace = 0 then return 0
  if m.top.iconSide = "left" then return iconSpace / 2
  return - (iconSpace / 2)
end function

' labelLeft: Where the label's BOX starts, given the slot the icon reserves.
'
' The counterpart to textCenterOffset: that one says where the text's centre
' goes, this one says where to put a box of width `buttonWidth - iconSpace` so
' the centre lands there. A leading icon takes the left of the button, so the
' box starts after it; a trailing icon takes the right, so the box starts at 0.
'
' Always >= 0, and that is the point (see setSizeAndCenterText). Offsetting the
' label node negatively puts a child outside its own component's background, and
' any LayoutGroup ancestor then silently repositions the whole button.
function labelLeft(iconSpace as float) as float
  if iconSpace = 0 then return 0
  if m.top.iconSide = "left" then return iconSpace
  return 0
end function

' layoutIcon: Place the icon for whichever role its side implies.
'
' The two roles differ once minWidth binds the button wider than its
' content, which is the common case here, not the exception. A leading icon
' stays glued to the text as it centres; a trailing chevron stays glued to the
' button's right edge, matching TrackDropdown's trigger.
sub layoutIcon(buttonWidth as float, buttonHeight as float, textWidth as float, iconSpace as float)
  if iconSpace = 0 then return

  iconY = int((buttonHeight - TEXT_BUTTON_ICON_SIZE) / 2)
  if m.top.iconSide = "left"
    ' Sits immediately left of the centred text, one gutter away.
    textLeft = (buttonWidth / 2) + textCenterOffset(iconSpace) - (textWidth / 2)
    slot = [int(textLeft - iconSpace), iconY]
  else
    slot = [int(buttonWidth - m.top.padding - TEXT_BUTTON_ICON_SIZE), iconY]
  end if

  ' Both occupants of the slot are placed, not only the visible one: they are
  ' mutually exclusive by visibility, and positioning whichever happens to be
  ' showing would leave the other stale for the frame it is switched on.
  m.buttonIcon.translation = slot
  m.buttonSpinner.translation = slot
end sub

' ── Loading ──────────────────────────────────────────────────────────────────

sub onLoadingChanged()
  ' A full re-layout, not only a visibility flip: entering the loading state
  ' claims the icon slot, so the label's centre moves. setSizeAndCenterText ends
  ' by calling applyLoadingState, so the spinner is placed by the same pass that
  ' made room for it.
  setSizeAndCenterText()
end sub

' applyLoadingState: Swap the label for a spinner, or back.
'
' By design it changes no geometry. It reads the background's already-measured
' rect and never calls setSizeAndCenterText(), so a button cannot resize on its
' way into or out of a loading state, which matters more here than it does on
' IconButton, because TextButton now sizes the whole dialog family: JRDialog and
' QuickConnectDialog derive their panel width, height and row position from a
' button's `isReady` size, and dialogLayout.bs / dialogs.bs each hold a hand-copy
' of its padding arithmetic. A spinner that resized its button would move every
' dialog in the app.
'
' Hiding the label rather than clearing its text is the same care: setSizeAndCenterText
' early-returns on empty text, so a cleared label would strand the button at
' whatever size it last had and never recover it when the text came back.
'
' The selected-state underline is left alone. It only draws while both
' showSelectedIndicator and isButtonSelected hold, and no button in the app is
' both a toggle and an action that can be in flight; threading isLoading through
' layoutSelectedIndicator and onSelectedIndicatorChanged would add two guards for
' a combination that does not occur.
sub applyLoadingState()
  loading = m.top.isLoading

  ' The spinner and a real icon are the same slot, so exactly one of them draws.
  ' A chevron beside a spinner would claim the value is still changeable while
  ' the button is busy.
  m.buttonIcon.visible = isValidAndNotEmpty(m.top.icon) and not loading

  if not loading or m.buttonBackground.width < 1
    ' Not measured yet is not a failure: setSizeAndCenterText calls back here as
    ' soon as it is, so the spinner arrives with the rest of the button rather
    ' than at [0,0].
    m.buttonSpinner.visible = false
    return
  end if

  ' Size and position both come from elsewhere: init() sizes the poster once, and
  ' layoutIcon placed it in the icon slot. Nothing to compute here.
  m.buttonSpinner.visible = true
end sub

' ── Selected indicator ───────────────────────────────────────────────────────

' layoutSelectedIndicator: Place the underline under the label's visible box.
'
' The label is vertically centered across the FULL button height, so its bottom
' edge follows from that centering. Deriving it from the padding instead is what
' broke once a minHeight (or a smaller padding) was in play.
sub layoutSelectedIndicator(buttonHeight as float, textLeft as float, textWidth as float, visibleTextHeight as float)
  if not m.top.showSelectedIndicator
    m.buttonIndicator.visible = false
    return
  end if

  textBottom = (buttonHeight + visibleTextHeight) / 2
  gapBelowText = buttonHeight - textBottom

  m.buttonIndicator.width = textWidth
  m.buttonIndicator.height = TEXT_BUTTON_INDICATOR_HEIGHT
  ' Not rounded: the previous implementation passed the raw float here, and
  ' keeping it makes the overhang tab bar's underline bit-identical to what it
  ' rendered before this moved into TextButton.
  m.buttonIndicator.translation = [textLeft, textBottom + ((gapBelowText - TEXT_BUTTON_INDICATOR_HEIGHT) / 2)]
  m.buttonIndicator.visible = m.top.isButtonSelected
end sub

sub onSelectedIndicatorChanged()
  m.buttonIndicator.visible = m.top.showSelectedIndicator and m.top.isButtonSelected
end sub

sub onPaddingChanged()
  setSizeAndCenterText()
end sub

sub onSizeChanged()
  setSizeAndCenterText()
end sub

sub onEnabledChanged()
  constants = m.global.constants

  if m.top.isEnabled
    m.top.background = constants.colorBackgroundSecondary
    m.top.border = constants.colorBackgroundSecondary
    m.top.textColor = constants.colorTextPrimary
  else
    m.top.background = constants.colorBackgroundPrimary
    m.top.border = constants.colorBackgroundPrimary
    m.top.textColor = constants.colorTextDisabled
  end if
end sub

sub setFocusBorderSize()
  if m.buttonBackground.width < 1 then return

  ' Border poster must be same size as background
  m.buttonBorder.width = m.buttonBackground.width
  m.buttonBorder.height = m.buttonBackground.height
end sub