Members

(static, constant) DEBUG_SPARE_ID_PREFIX

One prefix, used to BOTH build and clear the spares, so the two can't drift.

Default Value
  • debugSpareButton

(static, constant) ICON_BUTTON_ICON_SIZE

The icon's rendered edge length, and the reason every button on a capped row is the same width.

This is NOT "the size of the icon asset" — it is the LOAD BOX. IconButton.xml declares its icon Poster as width/loadWidth/height/loadHeight = 64 with loadDisplayMode="limitSize", so an oversized source is scaled down AS IT LOADS and bitmapWidth reports the scaled figure. That is what makes the uniform budget below true rather than merely hopeful, and it is worth stating plainly because two icons on the ItemDetails row ship a 96x96 FHD asset (album -> goToAlbumButton, person -> goToArtistButton) and still render at 64.

Measured, not assumed — via RTA against a rendered IconButton on a Streaming Stick 4K (OS 15.3.4, 1080p UI), because a Rooibos test cannot complete an image load synchronously:

icon = person_fhd.png (96x96 source) buttonIcon.loadDisplayMode = "limitsize" buttonIcon.loadWidth = 64 buttonIcon.bitmapWidth = 64 buttonBackground.width = 124

124 is exactly iconButtonBackgroundWidth(64), so the layout footprint is the budgeted 136. The gate for this lives in tests/source/unit/components/ui/IconButtonWidth.spec.bs, which pins the load box itself: widen it or drop limitSize and every cap in this file silently stops describing what renders.

TWO CONSTRAINTS THIS ENCODES, for anyone extending a capped row:

  • Every child of a capped row must be an IconButton (ResumeButton extends it, so it qualifies). A TextButton extends Group and sizes differently, so it would not fit this budget.
  • An icon asset LARGER than the load box is safe (it is scaled down); the load box is the thing that must not move.

Still open, and safe in both directions: whether Roku reports bitmapWidth in design units or physical pixels on a 720p-UI device (both test devices render at 1080p). Physical-pixel reporting would yield a NARROWER plate, so more buttons would fit than the cap allows - conservative, never an overrun.

Default Value
  • 64

(static, constant) ICON_BUTTON_PADDING

Padding around the icon inside the button background. Applied x4 horizontally (extra room so a two-word label has somewhere to go) and x2 vertically.

Default Value
  • 15

(static, constant) ICON_BUTTON_TEXT_EXTENSION

The caption is wider than the button by 6px on each side so labels don't wrap any earlier than they have to. It is what actually sets the button's layout footprint — the caption, not the background, is the widest thing in the node.

Default Value
  • 12

(static, constant) MORE_BUTTON_ICON

The shared More affordance, so both surfaces show the same glyph.

Default Value
  • pkg:/images/icons/more_horiz_$$RES$$.png

Methods

(static) appendDebugSpareButtons(group) → {void}

Pad a row so the #788 overflow can be SEEN on a device.

It cannot be seen otherwise. ItemDetails tops out at exactly 8 of 8 and the OSD at 7 of 10, so no library and no item type reaches either cap — and the 8th ItemDetails button is Trailer, which needs a local trailer FILE in the library (BuildGetLocalTrailersRequest), not a RemoteTrailers URL. Without this flag a reviewer cannot reach the boundary case either, let alone the menu.

Nothing but the m.global read lives here — the behaviour is in syncDebugSpareButtons() above, where a spec can reach it.

Parameters:
NameTypeDescription
groupobject
Returns:
Type: 
void

(static) applyButtonOverflow(group, stash, cap, moreText, moreIcon) → {void}

Split a row that no longer fits: move the tail into the stash and append a More button in its place. Restores first, so the split is always derived from the whole set rather than accumulated across calls.

Focus is carried by ID rather than index — the entire point of the split is that indices move. A button that ends up in the stash hands focus to More, which is where the user will now find it; landing back on index 0 instead would silently throw them to the other end of the row.

Parameters:
NameTypeDescription
groupobject
stashobject
capinteger
moreTextstring
moreIconstring
Returns:
Type: 
void

(static) captureRowFocus(group, stash) → {void}

The row focus, carried by BUTTON ID across one mutation bracket.

JRButtonGroup tracks focus as an INDEX, and OK dispatches whatever button sits at that index. A mutation that inserts or removes a button to the LEFT of the focused one leaves the highlight where it was while the index now names a different button — so OK runs the wrong action (a late Trailer insert turns "Delete" into "Play Trailer"), or nothing when the index runs past the end. Recording the ID here, before the caller mutates, and re-pointing the index at the end of the bracket is what keeps the two in agreement.

The button that really HAS focus wins over the index, since the index may already be wrong from an earlier mutation. The index is the fallback for a row that is not focused (it is still what the group restores focus to later).

Parameters:
NameTypeDescription
groupobject
stashobject
Returns:
Type: 
void

(static) claimRowFocus(stash, buttonId) → {void}

Hand the row's focus to buttonId when the bracket closes, instead of back to the button that had it when the bracket opened.

The capture above exists so a mutation does not MOVE focus by accident. A mutator that moves it on purpose has to say so, or the closing half faithfully puts focus back where it was: a new Resume that takes focus from Play (the lead action of the row changes) was reverted to Play this way on every item with progress. Call it between the two halves, next to the decision it records.

It must sit in the INNERMOST bracket. Brackets nest (a helper that brackets itself, called inside a caller's bracket), and the inner closing half consumes the capture, so a claim made in the outer bracket after such a helper returns is ignored. Outside any open bracket there is nothing to retarget: debug builds print, since an ignored claim is a deliberate focus move that silently never happens.

Parameters:
NameTypeDescription
stashobject
buttonIdstring
Returns:
Type: 
void

(static) consumeRowFocusCapture(group, stash) → {boolean}

Re-point the row's focus at the captured button, and consume the capture.

Acts ONLY when that button is still in the row, or has moved into the stash (More then stands in for it). When it is gone — the caller removed it — the caller's own choice stands: ItemDetails.setupButtons() deliberately falls back to index 0 when the focused button no longer exists, and removeResumeButtonWithFocus() hands focus on itself. Real focus is moved only if the row had it when the bracket began, so a refresh can never pull focus out of a child panel the user is in.

Parameters:
NameTypeDescription
groupobject
stashobject
Returns:

true when it set the focus index, false when it left the row alone

Type: 
boolean

(static) custodiedMoreButton(stash) → {object}

The row's More button while it is detached from the row.

A node field on the stash rather than anything on m: it is per-row (a screen with two overflowing rows gets two, with no collision), it needs no signature change at the call sites, and it keeps this file free of component state.

Parameters:
NameTypeDescription
stashobject
Returns:
Type: 
object

(static) handOnLostRowFocus(group, stash) → {void}

The focused button was REMOVED by the caller, and the row held focus.

Removing a focused node leaves no node focused anywhere in the scene, and the remote goes dead until something calls setFocus. Mutators that remove a button they know may be focused re-focus it themselves; one that does not (a Trailer button withdrawn by a late check while the user sits on it) left the screen deaf. The caller's index is kept — it is still the caller's choice — clamped to the row, and real focus is put there only when no node IN THE ROW holds it, so a caller that re-focused the row is never overridden. A caller that sends focus somewhere outside the row must do so after applyOverflow(): this cannot see focus elsewhere, because isInFocusChain() covers only a node's own subtree and reading a node's focusedChild is a script error.

Parameters:
NameTypeDescription
groupobject
stashobject
Returns:
Type: 
void

(static) iconButtonBackgroundWidth(iconWidth) → {integer}

The button's visible background plate.

Parameters:
NameTypeDescription
iconWidthinteger
Returns:
Type: 
integer

(static) iconButtonLayoutWidth(iconWidth) → {integer}

What the button occupies in a LayoutGroup: the caption's width, since it overhangs the background on both sides.

Parameters:
NameTypeDescription
iconWidthinteger
Returns:
Type: 
integer

(static) maxVisibleButtons(originX, spacing, itemWidth, rightBound) → {integer}

How many items of itemWidth, laid out from originX with spacing between them, fit before rightBound.

spacing is added back to the available width because the gaps sit BETWEEN items — n items occupy n*width + (n-1)spacing, not n(width+spacing).

Parameters:
NameTypeDescription
originXinteger

left edge of the row (the group's own translation x)

spacinginteger

LayoutGroup itemSpacings

itemWidthinteger

one item's layout footprint

rightBoundinteger

the first x the row may NOT reach

Returns:

the largest n that fits, never below 0

Type: 
integer

(static) moreButtonAnchor(originX, spacing, itemWidth, rowY, cap) → {object}

Where the More button sits, so the menu can be anchored to it.

DERIVED FROM THE SAME ARITHMETIC AS THE CAP, deliberately. More occupies the last slot the cap allows, so its position is the row's stride walked cap - 1 times — the identical expression maxVisibleButtons() inverted to produce cap. Reading the rendered node's boundingRect() would have been the obvious alternative and is worse in three ways: it is a render-thread node read the pure path does not otherwise need, it returns LOCAL coordinates that the caller would have to re-base onto the screen (see the comment above updateExtrasPanePosition in ItemDetails.bs for that trap), and it answers 0 until the row has laid out — which, for a menu opened from the very button being measured, is a race there is no reason to take.

Because it is the same arithmetic, the anchor CANNOT drift from the cap. Change the geometry and both move together or neither does.

Parameters:
NameTypeDescription
originXinteger

left edge of the row (the group's own translation x)

spacinginteger

LayoutGroup itemSpacings

itemWidthinteger

one item's layout footprint

rowYinteger

the row's top edge in SCREEN coordinates

capinteger

the ceiling from maxVisibleButtons()

Returns:

{ x, y, width } for computeDialogLayout's anchor

Type: 
object

(static) overflowMenuEntries(stash) → {object}

The label/icon pairs for the rows a More menu should show, read straight off the stashed buttons so the menu wears what the buttons were wearing.

Parameters:
NameTypeDescription
stashobject
Returns:

AA with shape: labels=[string], icons=[string], ids=[string]

Type: 
object

(static) overflowMenuSelection(result, ids) → {string}

Which button ID a More-menu result chose, or "" for a result that must NOT act.

Both surfaces' onMoreMenuResult handlers ACT — ItemDetails navigates, deletes and starts playback; the OSD seeks and switches tracks — so both have to decline a close that CODE made rather than the user, or a third party clearing the screen fires an item action from inside its own flow (dialogs.md). They had a line-for- line copy of that rule each. One copy, because the OSD's is otherwise unreachable from a spec: its OK press arrives through onKeyEvent, which a test cannot send.

Parameters:
NameTypeDescription
resultobject

the dialog's result AA

idsobject

the ids captured when the menu was opened, parallel to its rows

Returns:

the chosen button id, or "" to do nothing

Type: 
string

(static) removeChildrenWithIdPrefix(group, prefix) → {void}

Remove every child of group whose id starts with prefix.

Iterates BACKWARDS: removeChild shifts every later index down, so a forward loop skips the element after each removal.

Parameters:
NameTypeDescription
groupobject
prefixstring
Returns:
Type: 
void

(static) restoreOverflowedButtons(group, stash) → {void}

Put the row back the way it was built: drop the More button and return every stashed button to the row, in stash order, AT THE SLOT MORE WAS STANDING IN. Safe to call when nothing is stashed.

Restoring at More's index rather than appending is what makes the order robust. More marks exactly where the tail was cut, so putting the tail back there is correct even when the row has grown since the split — and a row that has grown since the split is precisely the case a caller creates by forgetting to restore before it mutates. Appending would have put that newer button ahead of the older tail and silently reordered the row.

This is also the START of the mutation bracket, so it records which button has focus — see captureRowFocus(). applyButtonOverflow() re-restores the row internally and must not re-record, which is why the body lives in restoreRow().

Parameters:
NameTypeDescription
groupobject
stashobject
Returns:
Type: 
void

(static) restoreRow(group, stash) → {void}

The restore itself, without recording focus. See restoreOverflowedButtons().

Parameters:
NameTypeDescription
groupobject
stashobject
Returns:
Type: 
void

(static) rowFocusIndex(group) → {integer}

The row's focus index, read and written through ONE guarded pair.

Both halves of the bracket touch buttonFocused, and this file's signatures take a plain group as object — so a group without the field (any bare Group) must not fault. It previously guarded with hasField() in one half and read the field directly in the other, which is a rule the second half had to remember rather than a property of the code. Reading an absent field yields invalid, and comparing that against an integer is a type mismatch, so the direct read was the unsafe half.

Parameters:
NameTypeDescription
groupobject
Returns:

the index, or -1 when the group cannot carry one

Type: 
integer

(static) setRowFocusIndex(group, index) → {void}

Parameters:
NameTypeDescription
groupobject
indexinteger
Returns:
Type: 
void

(static) splitForOverflow(count, cap) → {object}

Decide how a row of count buttons is divided by a cap of cap.

At or below the cap everything is visible and there is NO More button — the cap is a ceiling, not a quota. Above it, one slot is spent on More, so cap - 1 real buttons remain.

A consequence worth stating because it is load-bearing rather than incidental: the menu never holds a single item. Reaching overflow at all means count > cap, and cap-1 visible leaves at least two behind. A one-item More menu would be a worse affordance than the button it replaced, and this shape rules it out rather than relying on nobody hitting it.

visibleCount doubles as the index of the first button that moves into the menu — the row stops exactly where the menu starts. This used to also be returned as a separate overflowFrom, which no caller ever read and which the spec asserted was equal to visibleCount: two names for one number is a thing that can drift, so there is one.

Parameters:
NameTypeDescription
countinteger

total buttons the surface wants to show

capinteger

the ceiling from maxVisibleButtons()

Returns:

AA with shape: hasOverflow=boolean, visibleCount=integer

Type: 
object

(static) syncDebugSpareButtons(group, count) → {void}

Bring group to EXACTLY count spare buttons, whatever it started with.

Idempotent, and that is the whole point. OSD.setButtonStates() re-derives its row WITHOUT clearing it — it only removes buttons by id — and runs again on every item change, which for live TV is every program boundary. Appending unconditionally grew the row by count on each pass, silently moving the very cap boundary the flag exists to locate. ItemDetails never showed it because setupButtons() empties the group before it rebuilds.

The clear runs before the count <= 0 return, so lowering the flag to 0 and re-entering the screen actually removes the spares instead of stranding them.

Parameters:
NameTypeDescription
groupobject
countinteger
Returns:
Type: 
void

(static) takeCustodyOfMoreButton(stash, moreButton) → {void}

Parameters:
NameTypeDescription
stashobject
moreButtonobject
Returns:
Type: 
void