← README.md | 🌱
The Focus Plugin is one of the most powerful and feature-rich components of the Rotor Framework. It provides a comprehensive, production-grade focus management system designed specifically for the unique challenges of Connected TV (CTV) application development.
In CTV environments, focus management is not a secondary concern — it is the primary input mechanism. Unlike web or mobile applications where users interact through touch or mouse, CTV applications rely entirely on directional remote control navigation. This makes focus management a critical architectural concern that affects every aspect of the user experience.
The Focus Plugin was developed over multiple years across several proof-of-concept and white-label production projects, evolving from basic navigation handling into a sophisticated, runtime-adaptive system that handles even the most complex UI layouts with minimal configuration. Its design prioritizes developer experience and productivity: common patterns require just a few lines of configuration, while advanced scenarios are supported through composable features rather than complex workarounds.
- Hierarchical Focus Groups with bubbling and capturing resolution strategies, inspired by the DOM event model
- Automatic Spatial Navigation based on real-time widget geometry calculations, supporting both standard and rotated elements
- Static and Dynamic Direction Overrides with support for functions evaluated at navigation time
- Focus Memory that automatically remembers and restores the last focused item per group, with configurable depth
- Long-Press Detection with duration-based timer and handler callbacks for continuous scroll or special actions
- Focus State Query via injected
isFocused()widget method — always reflects current focus state - Programmatic Focus Control via injected widget methods (
setFocus,triggerKeyPress,enableFocusNavigation) - Deep Search Resolution that finds focus targets at any depth in the widget hierarchy
- Spatial Enter for geometry-aware group entry from specific directions
- Per-Direction Configuration for fine-grained control over spatial enter behavior
- Native Focus Bridge for integrating with SceneGraph's native focus system when needed
- Disabled Item Handling that prevents focus on inactive elements while maintaining navigation flow
The system is built on two complementary resolution strategies — bubbling (upward search through ancestor groups) and capturing (downward resolution to concrete focus items) — that together handle navigation seamlessly across deeply nested, dynamic UI hierarchies.
The Focus Plugin uses two separate template keys to distinguish between focusable items and focus containers:
| Template Key | Widget Role | Description |
|---|---|---|
focus: { ... } |
FocusItem | Individual focusable widget (button, card, menu entry, input field) |
focusGroup: { ... } |
FocusGroup | Container that manages navigation between child items and/or nested groups |
A widget cannot have both focus and focusGroup on the same template node. The plugin validates this at mount time and logs an error if both are present.
' FocusItem — receives focus directly
{
id: "submitButton",
nodeType: "Rectangle",
focus: {
onSelect: sub()
m.getViewModel().handleSubmit()
end sub
}
}
' FocusGroup — manages child focus items
{
id: "formContainer",
nodeType: "Group",
focusGroup: {
defaultFocusId: "emailInput",
down: "footerBar"
}
}The Focus Plugin implements two complementary focus resolution strategies:
When: User interaction (key press) cannot find a target within the current scope Direction: Child → Parent → Grandparent (upward through ancestor groups) Purpose: "I can't navigate further, let my parent groups decide"
Example: User presses UP from a focused item, but there's no item above in the current group. The plugin "bubbles up" through ancestor groups to find an alternative navigation path defined at a higher level.
When: A group or abstract target ID needs to be resolved to a concrete focusable item Direction: Group → Nested Group → FocusItem (downward through descendants) Purpose: "Found a target group/ID, now find the actual focusable item inside it"
This is a resolution operation that converts:
- Group reference → concrete FocusItem
- ID string → actual widget with focus capability
Example: Bubbling found "sidebarGroup", but the system needs a specific focusable item. Capturing recursively descends through the group's defaultFocusId chain until it finds a real FocusItem.
The capturing process searches deeply in hierarchies. If defaultFocusId doesn't match immediate children, it will:
- Search all descendant FocusItems (any depth)
- Search all nested Groups (any depth)
- Apply fallback logic if a matching Group is found
This means defaultFocusId: "deepItem" will find "deepItem" even if it's 3+ levels deep in the hierarchy.
Together they work as: User Action → Bubbling (↑ find alternative) → Capturing (↓ resolve target)
| Property | Type | Default | Description |
|---|---|---|---|
isEnabled |
boolean | true |
Enable/disable focus capability. Disabled items are skipped by both manual and spatial navigation. |
enableNativeFocus |
boolean | false |
When true, sets native SceneGraph focus on the underlying node (needed for keyboard input, video players, etc.) |
enableSpatialNavigation |
boolean | false |
Opt-in to automatic geometry-based navigation within the parent group |
up / down / left / right / back |
string | function | boolean | "" |
Static or dynamic navigation direction (see Direction Values) |
onFocusChanged |
sub(isFocused as boolean) |
invalid |
Called when focus state changes (receives true on focus, false on blur) |
onFocus |
sub() |
invalid |
Called when the widget gains focus |
onBlur |
sub() |
invalid |
Called when the widget loses focus |
onSelect / ok |
sub() |
invalid |
Called when the OK button is pressed while the widget is focused. Both onSelect and ok are accepted as aliases; if both are provided, onSelect takes priority. |
longPressHandler |
function(isLongPress as boolean, key as string) as boolean |
invalid |
Handle long-press events. Return true if handled (stops bubbling to ancestor groups). |
keyPressHandler |
function(key as string) as boolean |
invalid |
Handle key presses before any navigation or selection logic. Called for ALL keys (including direction keys, OK, back, and media keys). Return true to consume the key (navigation, onSelect, back — all skipped). Return false to let normal focus flow proceed. Non-navigation keys always bubble through ancestor FocusGroups' keyPressHandler callbacks (regardless of whether the FocusItem has a handler). |
| Property | Type | Default | Description |
|---|---|---|---|
defaultFocusId |
string | function() as string |
"" |
Default focus target when the group receives focus. Can be a static node ID or a function evaluated at resolution time. |
lastFocusedHID |
string | "" (auto-managed) |
Automatically remembers the last focused item's HID within this group |
enableLastFocusId |
boolean | true |
When true, the group stores lastFocusedHID and uses it on re-entry (bypassing defaultFocusId). When false, defaultFocusId is always evaluated on every focus entry — making it fully dynamic if it's a function. |
enableDeepLastFocusId |
boolean | false |
When true, ancestor groups also store lastFocusedHID from ANY descendant depth |
enableSpatialEnter |
boolean | object | false |
Enable spatial navigation when entering the group from a direction. Can be a boolean (all directions) or per-direction AA: { right: true, down: true } |
up / down / left / right / back |
string | function | boolean | "" |
Group-level navigation directions (activated via bubbling) |
onFocusChanged |
sub(isFocused as boolean) |
invalid |
Called when the group's focus chain state changes (true = a descendant has focus) |
onFocus |
sub() |
invalid |
Called when a descendant gains focus (group enters focus chain) |
onBlur |
sub() |
invalid |
Called when all descendants lose focus (group leaves focus chain) |
longPressHandler |
function(isLongPress as boolean, key as string) as boolean |
invalid |
Handle long-press events at group level. Return true to stop propagation. |
keyPressHandler |
function(key as string) as boolean |
invalid |
Handle non-navigation key presses (media, custom keys) that bubble up from the focused FocusItem. Called for any non-direction, non-OK key that was not consumed by the FocusItem's keyPressHandler. Return true to consume the key (stops bubbling to further ancestors). |
Direction properties (up, down, left, right, back) accept different value types with distinct behaviors:
| Value | Behavior |
|---|---|
"nodeId" (string) |
Static navigation to that widget — exits group immediately |
function() as string |
Dynamic, evaluated at runtime — return a node ID or boolean |
false |
Blocks the direction (nothing happens, key is consumed) |
true / "" / undefined |
Falls through to spatial navigation |
| Value | Behavior |
|---|---|
"nodeId" (string) |
Navigate to that group/item — may exit group |
function() as string |
Dynamic, evaluated at runtime |
true |
Blocks exit (stays on current element, key is consumed) |
false / "" / undefined |
Continue bubbling to next ancestor group |
| Lifecycle Hook | Purpose |
|---|---|
beforeMount |
Register focus item or group when widget mounts |
beforeUpdate |
Remove old focus config, merge new config, re-register |
beforeDestroy |
Unregister focus item/group, clear global focus if this widget held it |
- Configuration Parse — Plugin determines if the widget is a FocusItem (
focus:) or FocusGroup (focusGroup:) - Instance Creation — Creates
FocusItemClassorGroupClassinstance with configuration - Registration — Stores instance in
FocusItemStackorGroupStack - Hierarchy Tracking — Ancestor group relationships are computed dynamically via HID matching
- State Initialization — Adds
node.isFocusedfield automatically. For ViewModel widgets only (isViewModel = true), also initializesviewModelState.isFocusedtofalseif it does not already exist. Simple child widgets are excluded because they share the parent ViewModel'sviewModelStateby reference, which would cause conflicts. - Focus Application — When focus is set, calls
onFocusChanged,onFocus/onBlurcallbacks - State Update — Updates
node.isFocusedon all focus-configured widgets. UpdatesviewModelState.isFocusedonly on ViewModel widgets. Use the injectedm.isFocused()method for reliable per-widget focus queries. - Navigation Handling — Processes key events through static → spatial → bubbling priority chain
- Group Notification — Notifies all affected ancestor groups of focus chain changes
- Cleanup — Removes focus config and destroys instances on widget destroy
Widgets with focus configuration automatically receive these methods:
| Method | Signature | Description |
|---|---|---|
isFocused |
function() as boolean |
Check if this widget is focused (FocusItem) or in focus chain (Group). For widgets without focus config, returns false. |
setFocus |
function(command, enableNativeFocus?) as boolean |
Focus current widget (true), blur it (false), or focus another widget by ID (string). Returns true if focus changed. |
getFocusedWidget |
function() as object |
Returns the currently focused widget instance across the entire focus system |
enableFocusNavigation |
sub(enabled = true) |
Globally enable/disable all focus navigation (useful during animations or transitions) |
isFocusNavigationEnabled |
function() as boolean |
Check if focus navigation is currently enabled |
proceedLongPress |
function() as object |
Manually trigger the navigation action for the currently held long-press key |
isLongPressActive |
function() as boolean |
Check if a long-press is currently active |
triggerKeyPress |
function(key) as object |
Simulate a key press programmatically (for testing or automated navigation) |
setGroupLastFocusedId |
sub(id) |
Manually update the lastFocusedHID of this widget's focus group. If called on a FocusItem, updates the parent group. |
The simplest focus configuration — a button with visual feedback and selection handling:
{
id: "primaryAction",
nodeType: "Rectangle",
focus: {
onFocusChanged: sub(isFocused as boolean)
if isFocused
m.node.blendColor = "0x3399FFFF"
m.node.opacity = 1.0
else
m.node.blendColor = "0xFFFFFFFF"
m.node.opacity = 0.8
end if
' Tip: use m.isFocused() to query focus state
end sub,
onSelect: sub()
m.getViewModel().handleAction()
end sub
}
}A linear form where spatial navigation handles movement between fields automatically:
{
id: "loginForm",
nodeType: "Group",
focusGroup: {
defaultFocusId: "emailInput"
},
children: [
{
id: "emailInput",
viewModel: ViewModels.TextInputField,
focus: {
enableSpatialNavigation: true,
onSelect: sub()
m.openKeyboard("email")
end sub
}
},
{
id: "passwordInput",
viewModel: ViewModels.TextInputField,
focus: {
enableSpatialNavigation: true,
onSelect: sub()
m.openKeyboard("password")
end sub
}
},
{
id: "loginButton",
viewModel: ViewModels.ActionButton,
focus: {
enableSpatialNavigation: true,
onSelect: sub()
m.submitCredentials()
end sub
}
}
]
}After a keyboard dialog closes, focus can be moved programmatically:
sub onKeyboardClosed(fieldName as string)
if fieldName = "email"
m.setFocus("passwordInput")
else if fieldName = "password"
m.setFocus("loginButton")
end if
end subA classic CTV layout with a left-side menu and right-side content area:
' Main layout group
{
id: "appLayout",
nodeType: "Group",
focusGroup: {
defaultFocusId: function() as string
return m.viewModelState.wasContentFocused ? "contentArea" : "sideMenu"
end function,
onBlur: sub()
m.viewModelState.wasContentFocused = m.children?.contentArea?.node?.isFocused = true
end sub
},
children: [
' Sidebar menu
{
id: "sideMenu",
nodeType: "Group",
focusGroup: {
defaultFocusId: "menuHome",
right: "contentArea",
back: "contentArea"
},
children: [
{
id: "menuHome",
focus: {
onFocus: sub()
m.activateSection("home")
end sub
}
},
{
id: "menuSearch",
focus: {
onFocus: sub()
m.activateSection("search")
end sub
}
},
{
id: "menuSettings",
focus: {
onFocus: sub()
m.activateSection("settings")
end sub
}
}
]
},
' Content area
{
id: "contentArea",
nodeType: "Group",
focusGroup: {
defaultFocusId: function() as string
return m.viewModelState.activePageId
end function,
left: "sideMenu",
back: "sideMenu"
}
}
]
}Key techniques demonstrated:
- Dynamic
defaultFocusIdfunctions that resolve at focus time based on current state - Focus restoration — the parent group remembers whether content or menu was focused via
onBlur - Menu items use
onFocus(notonSelect) to trigger content changes passively during navigation
A scrollable card carousel that supports continuous scrolling via long-press:
{
id: "movieCarousel",
nodeType: "Group",
focusGroup: {
defaultFocusId: function()
return m.getCurrentCardId()
end function,
left: function()
return m.scrollToPreviousCard() ' Returns card ID or false
end function,
right: function()
return m.scrollToNextCard() ' Returns card ID or false
end function,
longPressHandler: function(isLongPress, key) as boolean
if key = "left" or key = "right"
direction = key = "left" ? -1 : 1
m.handleContinuousScroll(isLongPress, direction)
end if
return false ' Don't consume — let ancestors handle too
end function,
onFocusChanged: sub(isFocused)
if isFocused
m.showScrollIndicators()
else
m.hideScrollIndicators()
end if
end sub,
onBlur: sub()
m.pauseBackgroundPreview()
end sub
}
}Key techniques demonstrated:
- Direction functions that return a card ID (to navigate) or
false(to block at boundaries) - Long-press handler —
isLongPress = truestarts continuous scroll,isLongPress = falsestops it - Returning
falsefromlongPressHandlerallows the event to bubble to ancestor groups onBlurto clean up state when focus leaves the carousel
A vertical list of rails that expands the focused rail and collapses others:
{
id: "railList",
nodeType: "Group",
focusGroup: {
defaultFocusId: function()
return m.getActiveRailId()
end function,
up: function()
return m.navigateToRail(-1)
end function,
down: function()
return m.navigateToRail(1)
end function,
onFocus: sub()
m.expandActiveRail()
end sub,
onBlur: sub()
m.collapseAllRails()
end sub
}
}A card that dispatches its metadata when focused, enabling parent components to update:
{
id: "contentCard",
nodeType: "Poster",
focus: {
enableNativeFocus: false,
onFocusChanged: sub(isFocused)
m.updateVisualState(isFocused)
end sub,
onFocus: sub()
m.dispatchCardMetadata() ' Dispatches title, image, etc. to parent
end sub,
onSelect: sub()
m.openDetailPage()
end sub
}
}A modal that captures all focus — no direction key can exit:
{
id: "confirmDialog",
nodeType: "Group",
focusGroup: {
defaultFocusId: "confirmButton",
left: true, ' Block all directions
right: true,
up: true,
down: true,
back: true ' Block back button too
},
children: [
{
id: "confirmButton",
focus: {
onSelect: sub()
m.handleConfirm()
end sub
}
},
{
id: "cancelButton",
focus: {
onSelect: sub()
m.handleCancel()
end sub
}
}
]
}Inner group has no down-direction, so navigation bubbles to the parent group:
{
id: "pageLayout",
nodeType: "Group",
focusGroup: {
defaultFocusId: "categoryList",
down: "footerBar" ' Catches bubbling from inner groups
},
children: [
{
id: "categoryList",
nodeType: "Group",
focusGroup: {
defaultFocusId: "category1"
' No "down" direction → bubbles to pageLayout
},
children: [
{ id: "category1", focus: { onSelect: sub() ... end sub } },
{ id: "category2", focus: { onSelect: sub() ... end sub } }
]
}
]
}
{
id: "footerBar",
nodeType: "Group",
focusGroup: {
defaultFocusId: "footerAction1"
}
}A settings page where left-side menu and right-side panels stay synchronized:
' Settings page layout
{
id: "settingsLayout",
nodeType: "Group",
focusGroup: {
defaultFocusId: "optionsMenu"
},
children: [
{
id: "optionsMenu",
nodeType: "Group",
focusGroup: {
defaultFocusId: "menuItem_general",
right: "settingsPanel"
}
},
{
id: "settingsPanel",
nodeType: "Group",
focusGroup: {
left: "optionsMenu"
}
}
]
}When the panel carousel scrolls to a new section, the menu selection must update:
sub onPanelChanged(sectionIndex as integer)
menuItem = m.menuItems[sectionIndex]
m.children.optionsMenu.setGroupLastFocusedId("menuItem_" + menuItem.id)
end subThis ensures that pressing LEFT from the panel focuses the correct menu item — not the first item, but the one matching the current panel.
Menu items where regular entries trigger on focus (passive navigation), but action entries only trigger on OK press:
for each entry in m.menuEntries
itemConfig = {
id: "entry_" + entry.id,
viewModel: ViewModels.MenuItem
}
if entry.isAction
' Action items require explicit confirmation
itemConfig.focus = {
onSelect: sub()
m.executeAction(m.props.actionType)
end sub
}
else
' Navigation items activate on focus
itemConfig.focus = {
onFocus: sub()
m.showSection(m.props.sectionId)
end sub
}
end if
children.push(itemConfig)
end forA button whose navigation changes based on application state:
{
id: "adaptiveButton",
nodeType: "Rectangle",
focus: {
up: function() as string
if m.viewModelState.isExpanded
return "expandedHeader"
else
return "collapsedHeader"
end if
end function,
right: function() as string
if m.props.hasDetailPanel
return "detailPanel"
end if
return "" ' Fall through to spatial navigation
end function
}
}A content grid that focuses the geometrically closest item when entering from a direction:
{
id: "contentGrid",
nodeType: "Group",
focusGroup: {
defaultFocusId: "gridItem_0",
enableSpatialEnter: true, ' Use closest item when entering from any direction
left: "navigationMenu"
},
children: gridItems
}Per-direction spatial enter — only use spatial entry from the right, but default entry from other directions:
{
id: "categoryGrid",
nodeType: "Group",
focusGroup: {
defaultFocusId: "item_0",
enableSpatialEnter: {
right: true, ' Spatial entry from right
down: true ' Spatial entry from below
}
}
}A navigation hierarchy that remembers the last focused item at any depth:
{
id: "mainNavigation",
nodeType: "Group",
focusGroup: {
defaultFocusId: "topicList",
enableDeepLastFocusId: true, ' Remember focus at ANY depth
right: "detailPanel"
},
children: [
{
id: "topicList",
nodeType: "Group",
focusGroup: { defaultFocusId: "topic1" },
children: [
{
id: "topic1",
nodeType: "Group",
focusGroup: { defaultFocusId: "subtopic1_1" },
children: [
{ id: "subtopic1_1", focus: {} },
{ id: "subtopic1_2", focus: {} },
{ id: "subtopic1_3", focus: {} }
]
}
]
}
]
}When the user focuses subtopic1_3, navigates RIGHT to detailPanel, then returns LEFT — focus goes directly back to subtopic1_3 (not to topicList or topic1 defaults) because mainNavigation has enableDeepLastFocusId: true.
A group that always returns to its default, ignoring previous focus history:
{
id: "spotlightCarousel",
nodeType: "Group",
focusGroup: {
defaultFocusId: "spotlightFirst",
enableLastFocusId: false ' Always start from first item, never remember
}
}A panel with an animated vertical focus indicator bar:
{
id: "preferencePanel",
nodeType: "Group",
focusGroup: {
defaultFocusId: "firstToggle",
onFocusChanged: sub(isFocused as boolean)
indicator = m.getWidget("focusBar")
anim = m.animator("focus-bar-anim")
anim.create({
target: indicator,
opacity: isFocused ? 1 : 0,
duration: 0.2,
easeFunction: "linear"
}).play()
end sub
},
children: [
{
id: "focusBar",
nodeType: "Rectangle",
fields: { width: 4, height: 200, color: "0x3399FFFF", opacity: 0 }
},
' ... toggle items
]
}Disable navigation during animations to prevent race conditions:
sub onTransitionStart()
m.enableFocusNavigation(false)
end sub
sub onTransitionComplete()
m.enableFocusNavigation(true)
m.setFocus("firstItemInNewView")
end subA toggle that displays device state but cannot be interacted with:
{
id: "readOnlyToggle",
viewModel: ViewModels.ToggleButton,
props: {
label: "System Audio Guide",
isOn: CreateObject("roDeviceInfo").IsAudioGuideEnabled(),
enabled: false ' Cannot receive focus
},
focus: {
isEnabled: false ' Plugin skips this during navigation
}
}A video player widget that handles media remote keys (play, pause, rewind, etc.) via keyPressHandler. The handler fires for ALL keys before any navigation or selection logic:
{
id: "videoPlayer",
nodeType: "Video",
focus: {
enableNativeFocus: true,
keyPressHandler: function(key as string) as boolean
if key = "play"
m.togglePlayPause()
return true
else if key = "rewind"
m.seekBackward(10)
return true
else if key = "fastforward"
m.seekForward(10)
return true
else if key = "OK"
' Override OK to toggle play/pause instead of onSelect
m.togglePlayPause()
return true
end if
return false ' Direction keys etc. proceed with normal navigation
end function
}
}Group-level keyPressHandler as a fallback — catches media keys that the focused item didn't handle:
{
id: "mediaSection",
nodeType: "Group",
focusGroup: {
defaultFocusId: "mediaList",
keyPressHandler: function(key as string) as boolean
if key = "options"
m.showOptionsMenu()
return true
end if
return false ' Continue bubbling to ancestor groups
end function
}
}Key techniques:
- FocusItem
keyPressHandlerfires for ALL keys before navigation/selection — including direction keys, OK, back, and media keys - Return
trueto consume the key — navigation,onSelect, back handling are all skipped - Return
falseto let normal focus flow proceed (directions navigate, OK firesonSelect, back bubbles) - Non-navigation keys (media, custom) always bubble through ancestor FocusGroups'
keyPressHandlercallbacks — regardless of whether the focused FocusItem has a handler - FocusGroup
keyPressHandleronly receives non-navigation keys — direction keys and OK never reach group handlers via this path
Call the FocusPlugin's onKeyEventHandler directly from your MainScene and return the handled boolean:
' In your MainScene component:
function onKeyEvent(key as string, press as boolean) as boolean
return m.appFramework.plugins.focus.onKeyEventHandler(key, press).handled
end functionThis ensures only actually handled keys return true — unhandled keys return false so Roku can process them normally. A common mistake is return true for all keys, which swallows media keys (play, pause, rewind, etc.).
focus: { ... }→ FocusItem (focusable element)focusGroup: { ... }→ FocusGroup (container)- No focus config → Not part of the focus system
- String (Node ID): Static navigation to that element
- Function: Dynamic, evaluated at runtime (returns node ID or boolean)
false: Blocks the direction (nothing happens)true/""/ undefined: Falls through to spatial navigation
- FocusItem static direction (e.g.,
left: "actionButton") - Spatial navigation (within the parent group only)
- BubblingFocus (ask ancestor groups)
- Only works within a single group — cannot cross group boundaries
- Candidates include: FocusItems and direct child Groups with
enableSpatialNavigation: true enableSpatialNavigationdefault isfalse(opt-in)- When a Group is selected via spatial nav, capturing focus starts into that group
Group direction triggers only when:
- FocusItem has NO static direction for that key
- Spatial navigation found NOTHING
- BubblingFocus reaches this group
- String (Node ID): Navigate to that group/item — may exit group
true: Blocks exit — stays on current element, key is consumedfalse/ undefined: Continue bubbling to next ancestor group
Setting group.right = true does NOT prevent spatial navigation inside the group. It only blocks exiting the group when spatial navigation finds nothing.
Method 1: FocusItem explicit direction
' FocusItem exits immediately, regardless of group config
focus: { right: "targetOutsideGroup" }Method 2: Group direction (via BubblingFocus)
' Group exits when spatial nav fails inside it
focusGroup: { right: "adjacentGroup" }Method 3: Ancestor group direction
' Parent group catches bubbling when child groups pass
focusGroup: { right: "otherSection" }To prevent exit: group.left = true, group.right = true
Exception: FocusItem explicit directions still work — they bypass group blocking.
FocusItem (no direction) → Spatial nav (nothing) → Group.direction?
→ "nodeId" → CapturingFocus(nodeId) [EXIT]
→ true → STOP (stay on current element)
→ false/undefined → Continue to parent group
→ No more ancestors → Stay on current item
group.lastFocusedHID(if exists and still valid) [AUTO-SAVED]group.defaultFocusId[CONFIGURED]- Deep search (if defaultFocusId not found in immediate children)
- Child member fallback — if no
defaultFocusIdis configured, picks an available group member (focusItem) so focus is not lost. Note: the selection order is non-deterministic (depends on internal associative array iteration), sodefaultFocusIdis always recommended for predictable behavior.
enableLastFocusId(default:true) — Controls whether the immediate parent group storeslastFocusedHIDenableDeepLastFocusId(default:false) — When true, ancestor groups also storelastFocusedHIDfrom ANY descendant depth
Important: The effect of enableLastFocusId: false
When set to false, the group never stores lastFocusedHID. This means defaultFocusId is always used when the group receives focus — every single time, not just on the initial entry. If defaultFocusId is a function, it will be called on every focus entry, making it fully dynamic.
With the default enableLastFocusId: true, defaultFocusId (and its function) is only evaluated on the first entry into the group. After that, the stored lastFocusedHID takes priority and defaultFocusId is bypassed entirely.
Use case: Set enableLastFocusId: false when the group should always resolve focus dynamically (e.g., a spotlight rail that always starts from the first item, or a group where the entry point depends on runtime state).
Note: If enableLastFocusId: false is combined with no defaultFocusId, the group will always fall back to the child-member fallback on every entry — effectively always picking a non-deterministic member. This is rarely useful; always pair enableLastFocusId: false with a defaultFocusId (preferably a function) for meaningful dynamic behavior.
enableDeepLastFocusId use case: In nested groups (e.g., mainMenu > subMenu > menuItem), if you want the outer mainMenu to remember which deeply nested menuItem was last focused, set enableDeepLastFocusId: true on mainMenu.
- FocusItem node ID → Focus goes directly to it
- Group node ID → Capturing continues recursively on that group
- Non-existent ID → Deep search attempts across all descendants
Triggers when:
- CapturingFocus doesn't find
defaultFocusIdin immediate children defaultFocusIdis not empty
Searches:
- All descendant FocusItems (any depth)
- All nested Groups (any depth, applies their fallback logic)
When enableSpatialEnter = true (or { direction: true }) on a group:
- Entering the group uses spatial navigation from the incoming direction
- Finds the geometrically closest item instead of
defaultFocusId - Falls back to
defaultFocusIdif spatial finds nothing - Ignores
lastFocusedHID— always uses spatial calculation
User presses direction key:
1. FocusItem.direction exists? → Use it (may EXIT group)
2. Spatial nav finds item? → Navigate (STAYS in group)
3. BubblingFocus: Group.direction?
→ "nodeId" → EXIT to that target
→ true → BLOCK (stay)
→ undefined → Continue to ancestor
4. No more ancestors? → STAY on current item
Every focusable item should clearly indicate when it holds focus. Use onFocusChanged for visual state changes:
focus: {
onFocusChanged: sub(isFocused as boolean)
m.node.blendColor = isFocused ? "0x3399FFFF" : "0xFFFFFFFF"
m.node.opacity = isFocused ? 1.0 : 0.8
end sub
}Alternatively, use the injected m.isFocused() method in field expressions for reactive updates without explicit callbacks.
A group without defaultFocusId will fall back to picking an available child member, but the selection order is non-deterministic (depends on internal associative array iteration). Always specify defaultFocusId for predictable entry behavior:
' Always provide an entry point
focusGroup: {
defaultFocusId: "firstItem"
}Structure groups based on UI layout and user navigation expectations. Flat structures without groups make complex layouts difficult to manage.
Spatial navigation is great for grids and lists, but critical cross-section navigation should use explicit directions:
focusGroup: {
left: "sideMenu", ' Predictable cross-section nav
enableSpatialNavigation: false ' Don't rely on geometry for this
}onFocus— Trigger lightweight actions when the user navigates (preview content, highlight menu sections)onSelect— Trigger actions that require explicit user confirmation (submit form, open dialog, play video)
Prevent user navigation during animations or async transitions to avoid race conditions:
m.enableFocusNavigation(false)
' ... perform transition ...
m.enableFocusNavigation(true)When the navigation target depends on runtime state, use functions instead of static strings:
focusGroup: {
defaultFocusId: function() as string
return m.viewModelState.currentTabId
end function
}| Pitfall | Symptom | Solution |
|---|---|---|
| Focus Loops | Focus cycles between two widgets endlessly | Use false to block directions or carefully plan navigation paths |
Missing defaultFocusId |
Focus goes to a non-deterministic child member when entering a group | Always specify defaultFocusId for predictable behavior (RULE #11). The child-member fallback prevents focus loss but does not guarantee order. |
| Focusing disabled items | setFocus returns false, nothing happens |
Check isEnabled before programmatic focus; provide visual disabled state |
| Native focus conflicts | Unexpected focus jumps or lost input | Use enableNativeFocus consistently; avoid mixing native and plugin focus |
| Group hierarchy issues | Navigation doesn't work as expected | Ensure groups properly contain focus items in the widget tree |
| Spatial scope misunderstanding | Expecting spatial nav to cross groups | Spatial only works within one group (RULE #4); use static directions to exit |
| Blocking misunderstanding | group.right = true blocks internal spatial |
It only blocks exiting — internal spatial nav still works (RULE #7) |
Both focus and focusGroup |
Widget is silently skipped | A widget must have one or the other, never both |
enableSpatialNavigation assumption |
Spatial nav doesn't work | Default is false — must opt in explicitly |
' Check global navigation state
if m.isFocusNavigationEnabled() = false
print "Focus navigation is disabled globally"
m.enableFocusNavigation(true)
end if
' Check if any widget has focus
focused = m.getFocusedWidget()
if focused = invalid
print "No widget has focus — set initial focus"
m.setFocus("initialTarget")
end if
' Check if target widget is enabled
' (isEnabled: false prevents focus)- Verify
defaultFocusIdmatches an existing widget ID within the group (RULE #12) - Confirm focus items are actual children (descendants) of the group in the widget tree
- Test direction values:
trueblocks, string navigates,false/undefined continues bubbling (RULE #6) - Check bubbling flow through parent groups (RULE #10)
- If using
enableSpatialEnter, verify items have valid positions
- Verify
onFocusChangedcallback is properly defined - Use
m.isFocused()to query focus state programmatically - Inspect
m.node.isFocusedfield value directly for debugging
- Review the three methods for exiting a group (RULE #8)
- FocusItem explicit directions override group blocking (RULE #9 exception)
- Check navigation priority: static → spatial → bubbling (RULE #3)
Reference Documentation:
- Framework Initialization - Configuration, task synchronization, and lifecycle
- ViewBuilder Overview - High-level architecture and concepts
- Widget Reference - Complete Widget properties, methods, and usage patterns
- ViewModel Reference - Complete ViewModel structure, lifecycle, and state management
Plugin Documentation:
- Fields Plugin - Field management with expressions and interpolation
- FontStyle Plugin - Typography and font styling
- Observer Plugin - Field observation patterns
Additional Documentation:
- Cross-Thread MVI design pattern - State management across threads
- Internationalization support - Locale-aware interface implementation