Skip to content

UI focus management, virtual cursor, and theming for BGE.UI - #184

Merged
markwpearce merged 32 commits into
mainfrom
worktree-ui-focus-cursor-theme
Sep 3, 2026
Merged

UI focus management, virtual cursor, and theming for BGE.UI#184
markwpearce merged 32 commits into
mainfrom
worktree-ui-focus-cursor-theme

Conversation

@markwpearce

Copy link
Copy Markdown
Owner

Summary

Implements UI focus management, virtual-cursor navigation, and theming for BGE.UI (closes #133).

  • GameInput.consume() lets a widget stop an input event from also reaching GameEntity.onInput() the same frame.
  • Game.Play() now processes gameUi's input before entity input (was the reverse), which is what makes same-frame consumption possible at all — documented in CLAUDE.md.
  • UiContainer owns a virtual cursor: directional input moves it, hit-testing drives hover/focus (cursor-primary — hovering a widget focuses it), and OK/raw input dispatches to the focused widget.
  • BGE.UI.Theme (default colors/fonts/spacing) with a Game.defaultTheme and per-UiContainer override; any per-widget color field left unset resolves from there.
  • New widgets: Button, Checkbox, Select (inline-cycling), plus a Slider retrofit onto focus/theme.
  • examples/audio retrofitted off its old hand-rolled focusIndex pattern onto the new system — verified live on a real Roku device.

Spec: specs/2026-08-30-ui-focus-cursor-theme-design.md
Plan (15 tasks, executed via subagent-driven-development, several controller "Ruling:" annotations document real bugs found and fixed mid-execution): specs/2026-08-30-ui-focus-cursor-theme-plan.md

Notable things found and fixed along the way

This shipped several real bugs beyond the original plan's own text — each is called out in the plan/ledger, summarized here:

  • A pre-existing engine bug: UiContainer.draw() called child.draw() with no arguments, so UiWidget.draw(parent)'s parent parameter had always been dead code — every widget's theme-from-container-override was unreachable. Fixed at the root (child.draw(m)), which also required giving UiContainer.draw() itself a matching parent signature (it was previously 0-arg, which crashed on a nested UiContainer — e.g. Game.enableStandardDebugUi()'s panels — the moment the fix above started passing an argument).
  • A same-frame input-ordering bug in UiContainer.onInput(): cursor movement happened before dispatching to the focused widget, so a widget consuming Left/Right for its own purpose (e.g. Slider adjusting) never got the chance before the cursor had already moved away. Fixed by hit-testing first, dispatching next, moving the cursor only if not consumed.
  • A same-frame consumption leak: Game.bs dispatches a press and a synthesized held event to gameUi in the same frame; a widget consuming the press but not reacting to the unhandled held event released capture before entities were dispatched, defeating the feature's central guarantee. Fixed with a per-frame consumedThisFrame latch plus making Slider/Select react to (and throttle-repeat on) held input too.
  • Several smaller fixes found via a final whole-branch review: nested UiContainer children weren't receiving onInput at all (breaks the modal-dialog case the design cites), setInputEntity(gameUi) spammed a warning log every consuming frame, Checkbox was unfocusable when only height was set, and Theme.hoveredBackgroundColor defaulted equal to backgroundColor (invisible hover feedback).
  • A known toolchain limitation (pre-existing file can't resolve a symbol from a brand-new file, filed as brighterscript+bslint+rooibos-roku: pre-existing file can't resolve a symbol from a brand-new file #178) meant Theme lives in Style.bs rather than its own Theme.bs — documented inline.

Follow-ups filed rather than folded into this PR: #179 (text input widget), #180 (9-patch/image backgrounds), #181 (popup Select list), #182 (analog-stick cursor movement), #183 (Button/Checkbox still lack their own on-device demo surface — only Select/Slider are exercised via examples/audio).

Test plan

  • npm run check — lint, validate, headless tests: 933 passing
  • npm run check:all — every example project validates
  • examples/audio verified live on a real Roku device via rokubot: cursor seeding, row cycling with live label updates, focus moving to the volume slider (confirmed via the cursor visually disappearing into the slider's white fill), single-tap = single-step adjustment (no double-step), held-repeat at the throttled rate, OK play/pause + onAudioEvent, Options/Rewind/Back, and a clean Back-triggered quit
  • A final whole-branch review (separate from the 15 per-task reviews) found 2 Critical + 7 Important findings; all addressed in a follow-up commit, itself scoped-re-reviewed, which surfaced 3 further regressions (introduced by that very fix) that were caught, corrected, and re-verified by both automated tests and a second on-device pass

🤖 Generated with Claude Code

markwpearce and others added 30 commits August 30, 2026 22:38
…collisions

Two changes:

1. Pin `brighterscript` to exact "1.0.0-alpha.52" (no caret) in every
   package.json - the engine and every example/script project - instead
   of the floating "^1.0.0-alpha.50" range. That range already happened
   to resolve to alpha.52 today (confirmed via node_modules), but as a
   floating prerelease range it could silently drift to a newer alpha
   later. #175's bslint+rooibos-roku interaction bug is already known to
   be sensitive to exact alpha generation, so pin it everywhere rather
   than rely on install-time luck.

2. Fixes #167: every example's bsconfig.json (and the exampleTemplate
   scaffold used by `create-example`) copied the engine's entire src/
   tree - both plain source and SceneGraph component code-behind files
   - into one shared destination folder. Component files declare
   same-named subs (redraw, init, onRenderComplete, etc.) that BrighterScript
   treats as global, so flattening them together caused ~33
   duplicate-function errors the moment any example needed the engine's
   Shapes components (examples/scenegraph was the only example that
   already split them correctly).

   Fix: split each example's file copy into separate "source" and
   "components" destinations, matching examples/scenegraph's existing
   (correct) config - same pattern, just applied everywhere. controller's
   prior components-exclusion workaround and special controller-web copy
   are no longer needed (the split copy already lands controller-web at
   the right destination) and have been removed.

   Verified this needed a real device, not just bsc --validate: a first
   attempt at also nesting each dest under roku_modules/<packagename>
   (mirroring exactly what a real `ropm install` produces, confirmed by
   packing and installing the engine into a scratch consumer) validated
   fine via bsc/npm run check:all but failed to compile on a real Roku
   with "Install Failure: Compilation Failed. ShapeRenderTask" - a
   SceneGraph Task node quirk invisible to any static check. Reverted to
   the flatter destinations (still fully separating source from
   components, still fixing the collision) and confirmed both
   examples/scenegraph and examples/controller sideload and run cleanly
   on the same real device.

Verification:
- npm run check:all (lint, validate, headless tests, validate every
  example) passes clean.
- npm run build-examples packages all 19 examples with no errors.
- Sideloaded examples/scenegraph and examples/controller to a real Roku:
  both install and run correctly (controller's QR code, ship, and
  controller-web server all confirmed on screen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… file (works around known bslint+rooibos-roku new-file bug)
…consumption

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n resolve a container's theme override

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…'s signature

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Game.bs dispatches two events per press frame (the press, then a
synthesized 1000+code held event while the button stays down), and
UiContainer.onInput() decided setInputEntity/unsetInputEntity
independently for each - so the unconsumed held event immediately undid
the press's capture, defeating same-frame consumption entirely. The same
cause walked the cursor off a focused Slider/Select on every held frame
of a Left/Right tap.

- Slider/Select onInput() now accept held as well as press, so holding a
  direction keeps adjusting/cycling and the synthesized held event is
  consumed rather than leaking.
- UiContainer gains a per-frame consumedThisFrame latch (reset at the top
  of onUpdate(), which Game.processUiUpdate() runs once per frame after
  all onInput dispatch); capture and cursor-movement suppression both
  read the latch instead of this one event's consumed flag.
- The OK-release branch now consumes too, matching OK-press.
- Dropped the always-true `m.currentlyFocused.onInput <> invalid` check
  (a bound method reference is never invalid).
- Restored onInput forwarding to children that are themselves
  UiContainers (a nested container is never focusable, so focus-only
  dispatch never reached it) - the modal-dialog case the design cites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…h hover

- Game.Play()'s stale-inputEntityId check skipped getEntityByID() for
  gameUi/debugUi, which are never registered via addEntity() and so
  logged a warning every frame a UI container held input capture.
- Checkbox defaults width/height to 20 (matching draw()'s own boxSize
  fallback) so a Checkbox left unsized is still hit-testable -
  UiWidget.containsPoint() requires both to be > 0.
- Theme.hoveredBackgroundColor defaults to Silver instead of Gray, so
  Button/Checkbox/Select's hover state is visible out of the box.
- Theme's doc comment now describes resolution accurately (per-draw(),
  immediate parent's effectiveTheme() only, no tree walk), notes that
  font/fontSize are stored but not yet applied by any widget, and records
  why Theme lives in Style.bs (issue #178 toolchain bug).
- Removed Button.activateCallCount, test-only instrumentation that was
  shipping as public API; the spec counts calls on a test-local subclass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…-stick UI capture

Three regressions introduced by the previous final-review fix wave.

1. Slider/Select acted on BOTH the press and the same-frame synthesized held
   event Game.bs dispatches alongside it, double-stepping every tap (a single
   Right on a 2-option Select looked like nothing happened). They still CONSUME
   both events - that's what keeps input capture and the cursor from drifting -
   but only ACT on a press, or on a held event whose heldTimeMs has crossed
   BGE.UI.WIDGET_REPEAT_DELAY_MS since the last step.

2. UiContainer.onInput()'s nested-container forward ran unconditionally, so one
   press could fire both the outer container's focused widget and a nested
   container's focused widget. Now skipped when input.consumed is already true.

3. Game.Play()'s top-of-frame staleness check skipped clearing capture entirely
   for gameUi/debugUi, making capture sticky across frames and able to starve
   controller input indefinitely. Extracted to clearStaleInputCapture(), which
   skips only the warning-producing getEntityByID() lookup for those two ids
   while still clearing, keeping capture scoped to the single frame it was set.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…first FocusManager

- onInput() reverts to a plain broadcast to every child; a separate
  UiWidget.handleInput(input) as boolean (Roku onKeyEvent-style) is the
  only thing that participates in the focus/consume chain, called on the
  currently-focused widget only.
- Focus is global, not per-container: BGE.UI.FocusManager (Game.focusManager)
  owns the single focused-widget registry shared by every focusEnabled
  UiContainer, fixing double-handling when containers nest. GameInput.focusVisited
  guards against processing one event twice when it reaches nested containers.
- FocusNavigationMode.list (default) does discrete next/previous focus
  movement like a typical d-pad menu, with an off-by-default wrapFocus
  flag; FocusNavigationMode.pointer (opt-in) keeps the old spatial
  cursor/hit-test behavior for games that actually want it.
- debugUi.focusEnabled = false, so it's simple non-blocking broadcast only.
- OK release no longer consumes input, matching the documented contract.
- isValid() check between onMouseDown/onClick; seeding repositions the
  widget before reading its position; Game tracks isContainer() generally
  instead of special-casing gameUi/debugUi ids; theme-resolution deduped
  into UiWidget.resolveTheme().

Merged FocusManager into UiContainer.bs rather than its own file - a
pre-existing file referencing a brand-new one trips a bslint cannot-find-name
bug (confirmed reproducible with bslint alone, not just bslint+rooibos-roku).
- Game.dispatchOnInput() now always bypasses the currentInputEntityId
  capture gate for debugUi specifically, in both directions - a gameUi
  capture no longer blocks debugUi's onInput() the same frame.
- FocusManager.unregister() now resyncs currentlyFocusedIndex when an
  earlier, unfocused widget is removed, not just the focused one.
- FocusManager.update() is driven exactly once per event by
  Game.processFocusManagerInput(), not from UiContainer.onInput() during
  the recursive broadcast - removes the need for GameInput.focusVisited
  entirely rather than papering over the double-dispatch risk.
- FocusManager's pointer-mode seed repositions the whole ancestor chain
  (repositionAncestorChain), not just the immediate parent.
- examples/audio's AudioVolumeSlider now refreshes its label from
  handleInput() (where Slider actually mutates its value), not onInput()
  (plain broadcast, runs before the value changes).
- Extracted the press/held repeat-throttle duplicated across Slider,
  Select, and FocusManager's list/pointer navigation into a shared
  BGE.UI.RepeatThrottle (added to the existing Style.bs, not a new file -
  see the bslint new-file bug note).
- Button/Slider/Select/Checkbox no longer redraw their fill a second time
  just to layer the focus border underneath it - the border draws first
  and larger instead.
@markwpearce
markwpearce merged commit 5b83759 into main Sep 3, 2026
3 checks passed
@markwpearce
markwpearce deleted the worktree-ui-focus-cursor-theme branch September 3, 2026 17:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

UI focus management for interactive widgets (sliders, checkboxes, select boxes, text input)

1 participant