components_ui_Gradient.bs

import "pkg:/source/roku_modules/log/LogMixin.brs"

' Renders a smooth fade by stretching a tiny alpha-ramp bitmap (one Poster),
' tinted via blendColor. The assets are generated by
' scripts/generate/gradient-assets.js (npm run gradients:build) and are shared
' across every Gradient instance by the texture manager (~36KB decoded each,
' measured — texture cost follows the SOURCE bitmap dimensions, not the
' displayed size).
'
' History (#777): the previous implementation stacked up to 300 1–2px
' Rectangle strips. Zero texture memory, but the strips aliased into visible
' bars/gaps under the FHD→HD autoscale on 720p-UI devices, and each rebuild
' cost 37–77ms on the render thread of a low-end stick (measured; the Poster
' path is 0–2ms). A stretched ramp bitmap is scale-independent at every UI
' resolution and is the same technique Roku OS uses for its own player shade.
'
' Contract: fades one color out to nothing, with the transparent end in either
' position — startColor opaque → endColor alpha 0 (most call sites), or
' startColor alpha 0 → endColor opaque (ItemDetails' dropdownDimmer). The
' transparent end's RGB is ignored, since invisible is invisible. A fade
' between two VISIBLE colors needs a second hue that one blendColor cannot
' carry; that fires a debug warning.

sub init()
  m.log = new log.Logger("Gradient")

  ' Watch for field changes
  m.top.observeField("width", "recreateGradient")
  m.top.observeField("height", "recreateGradient")
  m.top.observeField("startColor", "recreateGradient")
  m.top.observeField("endColor", "recreateGradient")
  m.top.observeField("direction", "recreateGradient")
  m.top.observeField("rotateDegrees", "recreateGradient")

  ' Create initial gradient
  createGradient()
end sub

sub recreateGradient()
  ' Clear the existing poster
  m.top.removeChildren(m.top.getChildren(-1, 0))
  ' Create new gradient
  createGradient()
end sub

sub createGradient()
  width = m.top.width
  height = m.top.height
  startColor = m.top.startColor
  endColor = m.top.endColor
  direction = m.top.direction
  rotation = m.top.rotateDegrees

  ' 180°/270° rotations move the opaque end to the far edge (flipped ramp asset)
  flipped = (rotation = 180 or rotation = 270)

  ' Determine actual rendering direction based on original direction + rotation
  if direction = "vertical"
    if rotation = 0 or rotation = 180
      actualDirection = "vertical"
    else ' rotation = 90 or 270
      actualDirection = "horizontal"
    end if
  else ' direction = "horizontal"
    if rotation = 0 or rotation = 180
      actualDirection = "horizontal"
    else ' rotation = 90 or 270
      actualDirection = "vertical"
    end if
  end if

  ' The fade direction can be expressed two ways: rotateDegrees=180, or the
  ' inverted color order (startColor transparent, endColor opaque — see
  ' ItemDetails' dropdownDimmer). Normalize the inverted form: it's the flipped
  ' ramp tinted with the opaque end's color.
  tintColor = startColor
  if (startColor and &hFF) = 0 and (endColor and &hFF) <> 0
    tintColor = endColor
    flipped = not flipped
  end if

  ' A single tinted ramp fades one color out to nothing, so a fade between two
  ' VISIBLE ends is the shape it cannot express. Surface that loudly in debug
  ' builds instead of rendering the wrong colors silently.
  '
  ' Deliberately does NOT compare the two RGBs: a fully transparent end has no
  ' meaningful RGB, and callers set the two colors in separate statements, so
  ' the observer sees the half-updated state (PresentationBackdrop assigns
  ' endColor before startColor). An RGB comparison fired on that transient and
  ' warned on correct usage — confirmed on device during #777 review.
  if (startColor and &hFF) <> 0 and (endColor and &hFF) <> 0
    m.log.warn("Gradient needs one fully transparent end; got", startColor, endColor)
  end if

  if actualDirection = "vertical"
    if flipped
      assetName = "fade-v180"
    else
      assetName = "fade-v"
    end if
  else
    if flipped
      assetName = "fade-h180"
    else
      assetName = "fade-h"
    end if
  end if

  poster = createObject("roSGNode", "Poster")
  poster.width = width
  poster.height = height
  poster.loadDisplayMode = "scaleToFill"
  ' White ramp × blendColor = the opaque end's RGB, with alpha = ramp × that end's alpha
  poster.blendColor = tintColor
  poster.uri = "pkg:/images/gradients/" + assetName + ".png"
  m.top.appendChild(poster)
end sub