components_ui_button_IconButton.bs

import "pkg:/source/utils/misc.bs"
' The width metrics (ICON_BUTTON_PADDING / ICON_BUTTON_TEXT_EXTENSION /
' ICON_BUTTON_ICON_SIZE and the two width functions) live in buttonOverflow.bs
' because the row-overflow budget has to agree with what this file renders. Two
' copies of "how wide is an IconButton" is how a cap silently stops matching the
' thing it caps. See that file's header for the surfaces that depend on it.
import "pkg:/source/utils/buttonOverflow.bs"

const focusBorderOffset = 6 ' Used for positioning calculations only (matches 6px border in 9-patch image); does not control actual border rendering
const spinnerSize = 42 ' Spinner rendered smaller than 64px icon frame: icons have transparent padding so this matches the visible icon area

' LAYOUT NOTE FOR DEVELOPERS:
' IconButton text extends 12px wider than the button (6px on each side) to prevent label wrapping.
' The button background/border/icon are shifted 6px to the right to center under the text.
' When positioning IconButtons, account for this 6px offset if edge alignment is important.
' Example: If aligning to x=90, the button will actually appear at x=96 (90 + 6px offset).

sub init()
  m.buttonBackground = m.top.findNode("buttonBackground")
  m.buttonBorder = m.top.findNode("buttonBorder")
  m.buttonIcon = m.top.findNode("buttonIcon")
  m.buttonIcon.ObserveField("loadStatus", "OnImageLoadStatusChanged")
  m.buttonSpinner = m.top.findNode("buttonSpinner")
  m.buttonText = m.top.findNode("buttonText")

  m.top.observeField("isEnabled", "onEnabledChanged")
  m.top.observeField("isLoading", "onLoadingChanged")
  m.top.observeField("background", "onBackgroundChanged")
  m.top.observeField("iconBackground", "onIconBackgroundChanged")
  m.top.observeField("icon", "onIconChanged")
  m.top.observeField("text", "onTextChanged")
  m.top.observeField("height", "onHeightChanged")
  m.top.observeField("width", "onWidthChanged")
  m.top.observeField("padding", "onPaddingChanged")
  m.top.observeField("focusedChild", "onFocusChanged")

  ' Lay out at the load box now, not when the icon finishes loading: until then the
  ' backdrop has no size, so a button added to a live row (a real Resume or Shuffle
  ' replacing its placeholder, every button on first load) drew collapsed for a frame
  ' and then popped out. The load box caps every icon at this size and every icon a
  ' button uses fills it, so the layout it gets on load is the same one.
  layoutForIconSize(ICON_BUTTON_ICON_SIZE, ICON_BUTTON_ICON_SIZE)
  setFocusBorderSize()

  applyTheme()
end sub

sub applyTheme()
  constants = m.global.constants

  ' Set default colors
  m.top.background = constants.colorBackgroundSecondary
  m.top.focusBorder = constants.colorPrimary
  m.top.iconBackground = constants.colorTextPrimary
  m.top.focusBackground = constants.colorBackgroundSecondary
  m.top.iconFocusBackground = constants.colorTextPrimary
  m.iconSelectedColor = constants.colorSecondary
end sub

sub onFocusChanged()
  if m.top.hasFocus()
    ' Button border - change to focus color
    if m.top.enableBorder
      m.buttonBorder.blendColor = m.top.focusBorder
    else
      ' Border disabled - ensure it stays invisible
      m.buttonBorder.blendColor = m.top.background
    end if
    ' Button background
    m.buttonBackground.blendColor = m.top.focusBackground
    ' Button icon - respect selected state when focused
    if m.top.isButtonSelected
      m.buttonIcon.blendColor = m.iconSelectedColor
    else
      m.buttonIcon.blendColor = m.top.iconFocusBackground
    end if
  else
    ' Button border - match background color
    m.buttonBorder.blendColor = m.top.background
    ' Button background
    m.buttonBackground.blendColor = m.top.background
    ' Button icon - respect selected state when not focused
    if m.top.isButtonSelected
      m.buttonIcon.blendColor = m.iconSelectedColor
    else
      m.buttonIcon.blendColor = m.top.iconBackground
    end if
  end if
end sub

sub onBackgroundChanged()
  m.buttonBackground.blendColor = m.top.background
  ' Border matches background when not focused OR when borders are disabled
  if not m.top.hasFocus() or not m.top.enableBorder
    m.buttonBorder.blendColor = m.top.background
  end if
end sub

sub onIconBackgroundChanged()
  ' Only apply if not in selected state
  if not m.top.isButtonSelected
    m.buttonIcon.blendColor = m.top.iconBackground
  end if
end sub

sub onIconChanged()
  m.buttonIcon.uri = m.top.icon
end sub

sub onTextChanged()
  m.buttonText.text = m.top.text
end sub

sub OnImageLoadStatusChanged(event as object)
  status = event.GetData()

  if isValid(status) and status = "ready"
    setIconSize()
    setFocusBorderSize()
  end if
end sub

sub setIconSize()
  ' determine size of icon
  if m.buttonIcon.bitmapWidth > 0 and m.buttonIcon.bitmapHeight > 0
    layoutForIconSize(m.buttonIcon.bitmapWidth, m.buttonIcon.bitmapHeight)
  end if
end sub

' Size the backdrop around an icon of the given size, and center the icon in it.
sub layoutForIconSize(iconWidth as float, iconHeight as float)
  ' Width goes through the shared function because the row-overflow cap is
  ' computed from it; height does not, so it only shares the constant.
  ' int() because bitmapWidth is a float field and the metric is in whole
  ' design-space pixels, and our icon assets are square whole numbers.
  m.buttonBackground.width = iconButtonBackgroundWidth(int(iconWidth)) ' extra horiz padding for text
  m.buttonBackground.height = iconHeight + (ICON_BUTTON_PADDING * 2)

  ' Position icon centered in button background
  buttonOffset = ICON_BUTTON_TEXT_EXTENSION / 2
  m.buttonIcon.translation = [buttonOffset + ((m.buttonBackground.width - iconWidth) / 2), ((m.buttonBackground.height - iconHeight) / 2)]

  ' Update button and text positioning using shared method
  updateButtonPositioning()
end sub

sub onPaddingChanged()
  setIconSize()
end sub

sub onEnabledChanged()
  constants = m.global.constants

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

sub onSelectedChanged()
  if m.top.isButtonSelected
    ' Active/selected state - use secondary color for icon
    m.buttonIcon.blendColor = m.iconSelectedColor
  else
    ' Inactive state - restore default colors based on focus state
    if m.top.hasFocus()
      m.buttonIcon.blendColor = m.top.iconFocusBackground
    else
      m.buttonIcon.blendColor = m.top.iconBackground
    end if
  end if
end sub

' Updates button and text positioning based on ICON_BUTTON_TEXT_EXTENSION constant
' This centers button elements under the wider text label
sub updateButtonPositioning()
  ' Calculate offset to center button elements under wider text
  buttonOffset = ICON_BUTTON_TEXT_EXTENSION / 2

  ' Shift button elements to the right to center them under the wider text
  m.buttonBackground.translation = [buttonOffset, 0]
  m.buttonBorder.translation = [buttonOffset, 0]

  ' Text positioned at 0, extends equally on both sides
  m.buttonText.width = m.buttonBackground.width + ICON_BUTTON_TEXT_EXTENSION
  m.buttonText.translation = [0, m.buttonBackground.height + (focusBorderOffset * 2)]
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

  ' Update button and text positioning using shared method
  updateButtonPositioning()
end sub

' onLoadingChanged: Swap icon for a spinner while loading.
' Does not set isEnabled=false: the button stays focusable. OK blocking is handled by JRButtonGroup.
sub onLoadingChanged()
  if m.top.isLoading
    m.buttonIcon.visible = false
    m.buttonSpinner.visible = true

    ' Size the spinner
    m.buttonSpinner.poster.width = spinnerSize
    m.buttonSpinner.poster.height = spinnerSize

    ' Center spinner in button background
    buttonOffset = ICON_BUTTON_TEXT_EXTENSION / 2
    m.buttonSpinner.translation = [
      buttonOffset + (m.buttonBackground.width - spinnerSize) / 2,
      (m.buttonBackground.height - spinnerSize) / 2
    ]
  else
    m.buttonSpinner.visible = false
    m.buttonIcon.visible = true
  end if
end sub