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