components_JROverhang.bs

import "pkg:/source/api/ApiClient.bs"
import "pkg:/source/api/imageHelpers.bs"
import "pkg:/source/enums/HomeAction.bs"
import "pkg:/source/translationKeys.bs"
import "pkg:/source/utils/config.bs"
import "pkg:/source/utils/translate.bs"

sub init()
  m.top.id = "overhang"
  ' save node references
  m.logo = m.top.findNode("logo")
  m.logoPoster = createLogoPoster() ' save node to memory for faster load time
  m.title = m.top.findNode("title")
  m.clock = m.top.findNode("clock")
  m.isClockHidden = false

  ' Create user dropdown
  m.userDropdown = CreateObject("roSGNode", "JRDropdown")
  m.userDropdown.id = "userDropdown"
  m.userDropdown.menuWidth = 300
  m.userDropdown.visible = false ' hidden until user logs in
  m.top.appendChild(m.userDropdown)

  ' Reposition dropdown when trigger button width changes (e.g., username change while clock is hidden)
  m.userDropdown.observeField("triggerWidth", "onDropdownTriggerWidthChanged")

  ' Create overhang icon buttons (search and settings shortcuts)
  m.searchIcon = CreateObject("roSGNode", "JROverhangIcon")
  m.searchIcon.id = "searchIcon"
  m.searchIcon.icon = "pkg:/images/icons/search_$$RES$$.png"
  m.searchIcon.actionId = HomeAction.OPEN_SEARCH
  m.searchIcon.visible = false
  m.top.appendChild(m.searchIcon)

  m.settingsIcon = CreateObject("roSGNode", "JROverhangIcon")
  m.settingsIcon.id = "settingsIcon"
  m.settingsIcon.icon = "pkg:/images/icons/settings_$$RES$$.png"
  m.settingsIcon.actionId = HomeAction.OPEN_SETTINGS
  m.settingsIcon.visible = false
  m.top.appendChild(m.settingsIcon)

  ' Observe icon actions — proxy to overhang-level iconAction field
  m.searchIcon.observeField("selectedAction", "onIconAction")
  m.settingsIcon.observeField("selectedAction", "onIconAction")

  ' Observe icon focus exit signals for focus bridging
  m.searchIcon.observeField("requestFocusExit", "onSearchIconRequestsFocusExit")
  m.settingsIcon.observeField("requestFocusExit", "onSettingsIconRequestsFocusExit")

  ' Position dropdown so trigger text aligns vertically with overhang title/tabs.
  ' The trigger button has 30px padding, so offset the dropdown upward to compensate.
  positionUserDropdown()

  ' Create animations programmatically to avoid interpolator field reference issues
  createSlideAnimations()
end sub

' Create slide animations programmatically
sub createSlideAnimations()
  ' Create slide up animation
  m.slideUpAnimation = createObject("roSGNode", "Animation")
  m.slideUpAnimation.id = "slideUp"
  m.slideUpAnimation.duration = 0.5
  m.slideUpAnimation.repeat = false

  slideUpInterpolator = createObject("roSGNode", "Vector2DFieldInterpolator")
  slideUpInterpolator.key = [0.0, 0.5]
  slideUpInterpolator.keyValue = [[0, 0], [0, -200]]
  slideUpInterpolator.fieldToInterp = m.top.id + ".translation"

  m.slideUpAnimation.appendChild(slideUpInterpolator)
  m.top.appendChild(m.slideUpAnimation)

  ' Create slide down animation
  m.slideDownAnimation = createObject("roSGNode", "Animation")
  m.slideDownAnimation.id = "slideDown"
  m.slideDownAnimation.duration = 0.5
  m.slideDownAnimation.delay = 0.2
  m.slideDownAnimation.repeat = false

  slideDownInterpolator = createObject("roSGNode", "Vector2DFieldInterpolator")
  slideDownInterpolator.key = [0.0, 0.5]
  slideDownInterpolator.keyValue = [[0, -200], [0, 0]]
  slideDownInterpolator.fieldToInterp = m.top.id + ".translation"

  m.slideDownAnimation.appendChild(slideDownInterpolator)
  m.top.appendChild(m.slideDownAnimation)
end sub

sub onIsVisibleChanged()
  if m.top.isMoveAnimationDisabled
    m.top.translation = [0, 0]
    return
  end if
  if m.top.isVisible
    m.slideDownAnimation.control = "start"
    return
  end if

  m.slideUpAnimation.control = "start"
end sub

sub onTitleChanged()
  m.title.text = m.top.title
end sub

' ============================================
' USER DROPDOWN
' ============================================

sub onCurrentUserChanged()
  username = m.top.currentUser

  if username = ""
    ' No user logged in — hide the dropdown
    m.userDropdown.visible = false
    m.userDropdown.triggerText = ""
    m.userDropdown.triggerIconUri = ""
  else
    ' User logged in — configure and show the dropdown
    m.userDropdown.triggerText = username
    m.userDropdown.visible = true

    ' Set user avatar image
    localUser = m.global.user
    if isValidAndNotEmpty(localUser.primaryImageTag)
      m.userDropdown.triggerIconBlendColor = "0xFFFFFFFF"
      if m.global.server.serverUrl <> ""
        m.userDropdown.triggerIconUri = GetUserAvatarURL(localUser, 36, 36)
      end if
    else
      m.userDropdown.triggerIconBlendColor = m.global.constants.colorTextSecondary
      m.userDropdown.triggerIconUri = "pkg:/images/icons/person_36px_$$RES$$.png"
    end if

    ' Set menu items — user-related actions only
    m.userDropdown.items = [
      { title: translate(translationKeys.LabelChangeUser), id: HomeAction.CHANGE_USER },
      { title: translate(translationKeys.LabelChangeServer), id: HomeAction.CHANGE_SERVER },
      { title: translate(translationKeys.ButtonSignOut), id: HomeAction.SIGN_OUT }
    ]
  end if

  ' Refresh icon visibility — depends on both currentUser and showIcons
  updateIconVisibility()
end sub

' Controls icon visibility. Icons require both showIcons=true (set by the active screen)
' AND a logged-in user. This lets screens opt in (Home) without icons appearing on
' screens that only set currentUser (e.g., Settings).
sub onShouldShowIconsChanged()
  updateIconVisibility()
end sub

sub updateIconVisibility()
  shouldShow = m.top.shouldShowIcons and m.top.currentUser <> ""
  m.searchIcon.visible = shouldShow
  m.settingsIcon.visible = shouldShow

  if shouldShow
    positionOverhangIcons()
  end if
end sub

' Returns the user dropdown reference for direct focus management by screens
function getUserDropdown() as object
  return m.userDropdown
end function

' Returns the search icon reference for focus return observers
function getSearchIcon() as object
  return m.searchIcon
end function

' Returns the settings icon reference for focus return observers
function getSettingsIcon() as object
  return m.settingsIcon
end function

' Proxies icon selectedAction to the overhang-level iconAction field
sub onIconAction(msg as object)
  node = msg.getRoSGNode()
  m.top.iconAction = node.actionId
end sub

' ============================================
' FOCUS BRIDGING: TAB BAR <-> ICONS <-> DROPDOWN
' ============================================
' Focus chain (left to right): TabBar → Search → Settings → Dropdown
' Wraps: TabBar LEFT at first tab → Dropdown, Dropdown RIGHT → TabBar first tab

' Called when tab bar signals at its boundary — route into the icon/dropdown chain.
sub onTabBarRequestsUserMenu()
  direction = m.tabBar.requestFocusToUserMenu

  if direction = "right"
    ' Last tab → next element rightward: search icon (or dropdown if icons hidden)
    if m.searchIcon.visible
      m.searchIcon.setFocus(true)
    else if isValid(m.userDropdown) and m.userDropdown.visible
      m.userDropdown.setFocus(true)
    else
      ' Nothing to the right — wrap to first tab
      m.tabBar.focusedTabIndex = 0
    end if
  else if direction = "left"
    ' First tab → wrap to rightmost element: dropdown (or settings/search if dropdown hidden)
    if isValid(m.userDropdown) and m.userDropdown.visible
      m.userDropdown.setFocus(true)
    else if m.settingsIcon.visible
      m.settingsIcon.setFocus(true)
    else if m.searchIcon.visible
      m.searchIcon.setFocus(true)
    else
      ' Nothing — wrap to last tab
      tabCount = m.tabBar.findNode("tabContainer").getChildCount()
      m.tabBar.focusedTabIndex = tabCount - 1
    end if
  end if
end sub

' Called when search icon signals LEFT or RIGHT exit
sub onSearchIconRequestsFocusExit()
  direction = m.searchIcon.requestFocusExit

  if direction = "left"
    ' Search → left: tab bar last tab (or wrap to dropdown if no tabs)
    if isValid(m.tabBar) and m.tabBar.visible
      tabCount = m.tabBar.findNode("tabContainer").getChildCount()
      m.tabBar.focusedTabIndex = tabCount - 1
      m.tabBar.setFocus(true)
    else if isValid(m.userDropdown) and m.userDropdown.visible
      m.userDropdown.setFocus(true)
    else if m.settingsIcon.visible
      m.settingsIcon.setFocus(true)
    end if
  else if direction = "right"
    ' Search → right: settings icon
    if m.settingsIcon.visible
      m.settingsIcon.setFocus(true)
    else if isValid(m.userDropdown) and m.userDropdown.visible
      m.userDropdown.setFocus(true)
    end if
  end if
end sub

' Called when settings icon signals LEFT or RIGHT exit
sub onSettingsIconRequestsFocusExit()
  direction = m.settingsIcon.requestFocusExit

  if direction = "left"
    ' Settings → left: search icon
    if m.searchIcon.visible
      m.searchIcon.setFocus(true)
    else if isValid(m.tabBar) and m.tabBar.visible
      tabCount = m.tabBar.findNode("tabContainer").getChildCount()
      m.tabBar.focusedTabIndex = tabCount - 1
      m.tabBar.setFocus(true)
    end if
  else if direction = "right"
    ' Settings → right: dropdown
    if isValid(m.userDropdown) and m.userDropdown.visible
      m.userDropdown.setFocus(true)
    else if isValid(m.tabBar) and m.tabBar.visible
      m.tabBar.focusedTabIndex = 0
      m.tabBar.setFocus(true)
    end if
  end if
end sub

' Called when user dropdown signals LEFT or RIGHT exit
sub onUserDropdownRequestsTabs()
  direction = m.userDropdown.requestFocusExit

  if direction = "left"
    ' Dropdown → left: settings icon (or search, or tab bar)
    if m.settingsIcon.visible
      m.settingsIcon.setFocus(true)
    else if m.searchIcon.visible
      m.searchIcon.setFocus(true)
    else if isValid(m.tabBar) and m.tabBar.visible
      tabCount = m.tabBar.findNode("tabContainer").getChildCount()
      m.tabBar.focusedTabIndex = tabCount - 1
      m.tabBar.setFocus(true)
    end if
  else if direction = "right"
    ' Dropdown → right: wrap to tab bar first tab (or search if no tabs)
    if isValid(m.tabBar) and m.tabBar.visible
      m.tabBar.focusedTabIndex = 0
      m.tabBar.setFocus(true)
    else if m.searchIcon.visible
      m.searchIcon.setFocus(true)
    else if m.settingsIcon.visible
      m.settingsIcon.setFocus(true)
    end if
  end if
end sub

' ============================================
' POSITIONING
' ============================================

' Hides the overhang clock and repositions the user dropdown to fill the freed space.
' Called by Home.bs when the user's uiDesignHideClock setting is enabled.
sub hideClock()
  if isValid(m.clock)
    m.top.removeChild(m.clock)
    m.clock = invalid
  end if
  m.isClockHidden = true
  positionUserDropdown()
end sub

' Repositions the dropdown when its trigger button width changes.
' Only matters when clock is hidden (right-aligned mode), since
' the left-aligned default position doesn't depend on trigger width.
sub onDropdownTriggerWidthChanged()
  if m.isClockHidden
    positionUserDropdown()
  end if
end sub

' Positions the user dropdown in the overhang.
' When the clock is visible, the dropdown is left-aligned at x=1450 (its original position).
' When the clock is hidden, the dropdown is right-aligned so its right edge meets the
' 5% action-safe boundary (x=1824), matching where the clock's right edge sits.
sub positionUserDropdown()
  if not isValid(m.userDropdown) then return

  dropdownPadding = m.userDropdown.padding
  yPos = 54 - dropdownPadding

  if m.isClockHidden
    ' Right-align: position so the trigger button's right edge lands at x=1824.
    ' triggerWidth includes padding on both sides, so the full button ends at 1824.
    triggerWidth = m.userDropdown.triggerWidth
    if triggerWidth > 0
      xPos = 1824 - triggerWidth - dropdownPadding
    else
      ' Trigger hasn't rendered yet — use default position; will reposition on triggerWidth change
      xPos = 1450 - dropdownPadding
    end if
  else
    xPos = 1450 - dropdownPadding
  end if

  m.userDropdown.translation = [xPos, yPos]

  ' Reposition icons relative to the dropdown whenever it moves
  if m.searchIcon.visible
    positionOverhangIcons()
  end if
end sub

' Positions the search and settings icons to the left of the user dropdown
' with equal spacing between all three elements (search, settings, dropdown).
sub positionOverhangIcons()
  if not isValid(m.searchIcon) or not isValid(m.settingsIcon) then return
  if not isValid(m.userDropdown) then return

  iconSize = 66 ' Must match OVERHANG_ICON_SIZE in JROverhangIcon.bs
  gap = 24 ' Equal spacing between elements

  ' Anchor from the dropdown's visible left edge.
  ' The dropdown's translation already includes its own padding offset
  ' (set by positionUserDropdown), and the buttonBackground starts at [0,0]
  ' within the dropdown, so translation[0] IS the visible button edge.
  dropdownX = m.userDropdown.translation[0]
  dropdownPadding = m.userDropdown.padding
  yPos = 54 - dropdownPadding ' Aligns with dropdown (54 is the overhang baseline)

  settingsX = dropdownX - gap - iconSize
  searchX = settingsX - gap - iconSize

  m.settingsIcon.translation = [settingsX, yPos]
  m.searchIcon.translation = [searchX, yPos]
end sub

' component boolean field isVisible has changed value
sub onIsLogoVisibleChanged()
  if m.top.isLogoVisible
    if not isValid(m.logo)
      m.logo = m.logoPoster
      m.top.appendChild(m.logoPoster)

      m.title.translation = [360, 51]
      m.title.maxWidth = 300
    end if
  else
    ' remove the logo
    if isValid(m.logo)
      m.top.removeChild(m.logo)
      m.logo = invalid
      m.title.translation = [96, 51]
      m.title.maxWidth = 1560
    end if
  end if

  positionTabBar()
end sub

' Create and return a logo poster node
function createLogoPoster()
  logoPoster = createObject("roSGNode", "Poster")
  logoPoster.id = "logo"
  logoPoster.uri = "pkg:/images/branding/logo.png"
  logoPoster.translation = "[96, 54]"
  logoPoster.width = "180"
  logoPoster.height = "39"

  return logoPoster
end function

' ============================================
' TAB BAR
' ============================================

' onTabsChanged: Swaps between title label and tab bar.
' Empty/invalid tabs restores the title label; non-empty creates a JRTabBar.
sub onTabsChanged()
  tabs = m.top.tabs

  if not isValidAndNotEmpty(tabs)
    ' No tabs — hide the tab bar (preserve it for instant reuse on return)
    ' and restore the title label
    if isValid(m.tabBar)
      m.tabBar.unobserveField("isReady") ' cancel any pending ready observer before restoring title
      m.tabBar.visible = false
    end if
    m.title.visible = true
    return
  end if

  if not isValid(m.tabBar)
    m.tabBar = CreateObject("roSGNode", "JRTabBar")
    m.tabBar.observeField("selectedTabId", "onTabBarSelectionChanged")
    m.tabBar.observeField("requestFocusToUserMenu", "onTabBarRequestsUserMenu")
    m.top.appendChild(m.tabBar)
  end if

  ' Set up focus bridging between tab bar and dropdown
  if isValid(m.userDropdown)
    m.userDropdown.unobserveField("requestFocusExit") ' prevent stacking
    m.userDropdown.observeField("requestFocusExit", "onUserDropdownRequestsTabs")
  end if

  m.tabBar.visible = true
  positionTabBar()

  ' Sync current selection before setting tabs (so the correct tab starts selected)
  if isValidAndNotEmpty(m.top.selectedTabId)
    m.tabBar.selectedTabId = m.top.selectedTabId
  end if

  m.tabBar.tabs = tabs

  ' After setting tabs, check if they were preserved (return path) or rebuilt (init).
  ' Preserved tabs are already sized → hide title instantly.
  ' Rebuilt tabs need render tracking → keep title visible until ready.
  if m.tabBar.isReady
    m.title.visible = false
  else
    m.tabBar.unobserveField("isReady") ' prevent stacking duplicate observers on rapid tab changes
    m.tabBar.observeField("isReady", "onTabBarReady")
  end if
end sub

' Called when newly created tabs finish sizing. Swaps the title for the
' fully-formed tab bar in a single frame — no blank gap or sizing dance.
sub onTabBarReady()
  if isValid(m.tabBar)
    m.tabBar.unobserveField("isReady")
  end if
  m.title.visible = false
end sub

' Positions the tab bar so tab text aligns with where the title label sits.
' TextButton adds padding around the text, so we offset the tab bar to compensate.
sub positionTabBar()
  if not isValid(m.tabBar) then return

  tabPadding = 30 ' Must match JRTab's padding value
  fontSize = m.global.constants.fontSizeLarge
  buttonHeight = int(fontSize * 0.7) + (tabPadding * 2)
  yOffset = int((buttonHeight - fontSize) / 2.0)

  if isValid(m.logo)
    m.tabBar.translation = [360 - tabPadding, 51 - yOffset]
  else
    m.tabBar.translation = [96 - tabPadding, 51 - yOffset]
  end if
end sub

' Proxy tab bar selection changes to the overhang's selectedTabId field
sub onTabBarSelectionChanged()
  if isValid(m.tabBar)
    m.top.selectedTabId = m.tabBar.selectedTabId
  end if
end sub

' Returns the tab bar node reference for direct focus management by screens
function getTabBar() as object
  return m.tabBar
end function

sub resetTime()
  if isValid(m.clock)
    m.clock.callFunc("resetTime")
  end if
end sub