From 8f9e5dd3f7cba7137bf448bfc128151ebeee1d46 Mon Sep 17 00:00:00 2001
From: Eric Anderson
Date: Sun, 2 Aug 2026 21:59:51 -0700
Subject: [PATCH 1/8] feat (UiStack): per-child layout, optional focus-scope
ownership
---
README.md | 102 ++++++++++++++++
button.ts | 60 ++++++++--
grid.ts | 28 ++++-
row.ts | 36 +++++-
samples.ts | 187 ++++++++++++++++++++++++++++-
stack.ts | 343 ++++++++++++++++++++++++++++++++++++++++++++++++-----
6 files changed, 711 insertions(+), 45 deletions(-)
diff --git a/README.md b/README.md
index c051d03..7414daa 100644
--- a/README.md
+++ b/README.md
@@ -10,6 +10,8 @@ It builds on [**ui-core**](https://github.com/humanapp/ui-core) and adds reusabl
- Add `UiLabel` and `UiButton` to a screen with `add()` instead of writing a
custom `render()` method for text or activation.
+- Put several rows of controls in a `UiStack` with a `scopeId` when they should
+ lay out independently but navigate as one.
- Open a `UiPicker` for a short modal choice such as a confirmation dialog.
- Open a `UiNumericEntryModal` for number entry through a built-in keypad.
- Open a `UiTextEntryModal` for short string entry through a compact keyboard.
@@ -309,6 +311,99 @@ class DataGraphScreen extends ui.UiScreen {
+## 6. Stack Rows Into One Focus Scope
+
+A `UiRow` or `UiGrid` added to a screen owns its own focus scope, and focus does
+not cross scopes on its own. When a screen wants several rows that lay out
+independently but navigate as one, put them in a `UiStack` and give the stack a
+`scopeId`. The stack registers the rows under its scope and answers directional
+movement for all of them, so up and down cross from row to row with no
+navigation code in the screen. Views without focus targets, such as `UiLabel`,
+simply take part in layout and are skipped by movement.
+
+```ts
+const SETUP_SCOPE = "setup"
+
+class SetupScreen extends ui.UiScreen {
+ constructor(runtime: ui.UiRuntime) {
+ super(runtime)
+ this.backgroundColor = 6
+
+ this.add(
+ new ui.UiStack({
+ orientation: "column",
+ scopeId: SETUP_SCOPE,
+ alignment: "center",
+ wrap: true,
+ gap: 0,
+ children: [
+ { view: new ui.UiLabel("Size", 15) },
+ { view: this.row(["S", "M", "L"], 34), gapBefore: 3 },
+ { view: new ui.UiLabel("Speed", 15), gapBefore: 4 },
+ { view: this.row(["Slow", "Fast"], 40), gapBefore: 3 },
+ { view: this.row(["Done"], 44), gapBefore: 10 },
+ ],
+ }),
+ {
+ x: 0,
+ y: 4,
+ width: ui.STANDARD_DISPLAY_WIDTH,
+ horizontalAlignment: "center",
+ },
+ )
+ }
+
+ // No scopeId here: the stack assigns its own to every row it holds.
+ private row(texts: string[], controlWidth: number): ui.UiRow {
+ const controls: ui.UiControl[] = []
+ for (let i = 0; i < texts.length; i++)
+ controls.push(ui.button(texts[i], texts[i], () => {}))
+
+ return new ui.UiRow({
+ controls,
+ controlSize: { width: controlWidth, height: 20 },
+ controlStyle: ui.UiButtonStyles.LightShadowedWhite,
+ gap: 4,
+ })
+ }
+}
+```
+
+A row or grid built for a stack needs no `scopeId` of its own; the stack assigns
+one, so the scope is named once. Give a row its own `scopeId` when it is added
+to a screen directly, which is what it needs to register a scope at all.
+
+Each child is a record holding the view plus whatever placement belongs to that
+child rather than to the whole stack. The stack's `alignment` places every child
+on the cross axis, so rows of three, two, and one control all share a center
+line; a child overrides it with its own `alignment`, and adds `gapBefore` or
+`gapAfter` for spacing of its own.
+
+Children keep laying themselves out, so any focusable view composes: a `UiGrid`
+contributes one navigation row per grid row, a `UiButton` contributes one
+target, and a nested `UiStack` passes the scope on to its own children. A nested
+stack with `orientation: "row"` reports its children as a single merged row,
+which is the way to make a button and a row read as one strip of controls.
+
+To spread children instead of spacing them by hand, give the stack a `justify`
+of `"center"`, `"end"`, or `"spaceBetween"` and a placement that stretches it
+along its axis:
+
+```ts
+this.add(stack, {
+ x: 0,
+ y: 4,
+ width: ui.STANDARD_DISPLAY_WIDTH,
+ height: ui.STANDARD_DISPLAY_HEIGHT - 8,
+ horizontalAlignment: "stretch",
+ verticalAlignment: "stretch",
+})
+```
+
+Without a `scopeId` a stack is layout only: it arranges and renders its children
+and registers no focus scope, which is all that is needed for a column of
+labels.
+
## A Few Working Rules
- Use screen modals for short blocking tasks such as number entry, text entry,
@@ -316,6 +411,13 @@ class DataGraphScreen extends ui.UiScreen {
- Prefer `UiLabel` and `UiButton` over custom `render()` for static text and
activation. Drop down to custom drawing on the `DrawSurface` when the screen
needs something the controls do not provide.
+- A `UiStack` takes a `scopeId` when it owns a focus scope for its children. It
+ has none of its own in two cases: when it holds nothing focusable, such as a
+ column of labels, and when it is nested in a stack that already owns one and
+ passes it down.
+- Rows and grids added to a screen separately keep separate scopes, and focus
+ does not cross between them. Put them in one scope-owning stack when they
+ should navigate as one.
- Reuse `Rect`, `Size`, and `UiMeasuredSize` objects in frame code when practical. Avoid allocations in the render callback.
## Using **ui-controls**
diff --git a/button.ts b/button.ts
index 34355f3..dd07ca7 100644
--- a/button.ts
+++ b/button.ts
@@ -494,7 +494,9 @@ namespace ui {
* Screen-managed button with retained layout, focus, rendering, and activation.
*/
export class UiButton
- implements UiFocusableView>, UiFocusNavigationProvider
+ implements
+ UiComposableFocusView>,
+ UiFocusNavigationProvider
{
public readonly layoutSpec: UiLayoutSpec
public readonly finalRect: Rect
@@ -542,6 +544,15 @@ namespace ui {
return this.scopeId_
}
+ /**
+ * Adopts an owner scope, so a parent view can navigate this button
+ * together with its siblings. The target id is derived from the scope,
+ * so this runs before focus registration.
+ */
+ public setScopeId(scopeId: UiFocusScopeId): void {
+ this.scopeId_ = scopeId
+ }
+
/**
* Caller-owned control record rendered by this button.
*/
@@ -612,24 +623,59 @@ namespace ui {
/**
* Registers this button's focus scope and target.
*/
- public registerFocusTargets(focus: UiFocusState): void {
+ public registerFocusTargets(
+ focus: UiFocusState,
+ scopeOptions?: UiFocusScopeOptions,
+ ): void {
const targetId = this.targetId()
const focusable = this.isNavigationControl()
- focus.setScope({
- id: this.scopeId_,
- preferredTargetId: focusable ? targetId : undefined,
- })
- if (!focusable) return
+ focus.setScope(
+ scopeOptions || {
+ id: this.scopeId_,
+ preferredTargetId: focusable ? targetId : undefined,
+ },
+ )
+ // Registered hidden rather than skipped when it cannot take focus,
+ // so that focus state drops a retained target when the button is
+ // hidden between registrations.
focus.setTarget({
id: targetId,
scopeId: this.scopeId_,
rect: this.finalRect,
scrollOwnerId: this.scrollOwnerId_,
scrollRect: this.scrollOwnerId_ ? this.finalRect : undefined,
+ hidden: !focusable,
activatable: true,
})
}
+ /**
+ * Navigation targets as a single row holding this button, for parent
+ * views that compose several views into one focus scope.
+ */
+ public navigationRows(): UiFocusNavigationTarget[][] {
+ return [
+ [
+ {
+ id: this.targetId(),
+ rect: this.finalRect,
+ scrollOwnerId: this.scrollOwnerId_,
+ scrollRect: this.scrollOwnerId_
+ ? this.finalRect
+ : undefined,
+ hidden: !this.isNavigationControl(),
+ },
+ ],
+ ]
+ }
+
+ /**
+ * Returns this button's target id when it can take focus.
+ */
+ public resolvePreferredTargetId(): UiFocusId | undefined {
+ return this.isNavigationControl() ? this.targetId() : undefined
+ }
+
/**
* Registers button navigation with a focus input controller.
*/
diff --git a/grid.ts b/grid.ts
index 62efae6..283f1a0 100644
--- a/grid.ts
+++ b/grid.ts
@@ -5,9 +5,11 @@ namespace ui {
export interface UiGridOptions
extends UiControlCollectionOptions, UiControlGridLayoutOptions {
/**
- * Focus scope id for this grid.
+ * Focus scope id for this grid. Required for a grid added to a screen
+ * on its own; omit it for a grid placed in a `UiStack` that owns a
+ * scope, which assigns its own.
*/
- scopeId: UiFocusScopeId
+ scopeId?: UiFocusScopeId
/**
* Scroll owner used when this grid is arranged in scroll content.
@@ -46,7 +48,7 @@ namespace ui {
/**
* Renders and navigates a rectangular or ragged control grid.
*/
- export class UiGrid implements UiFocusableView> {
+ export class UiGrid implements UiComposableFocusView> {
public readonly layoutSpec: UiLayoutSpec
public readonly finalRect: Rect
public layoutDirty: boolean
@@ -97,6 +99,15 @@ namespace ui {
return this.scopeId_
}
+ /**
+ * Adopts an owner scope, so a parent view can navigate this grid
+ * together with its siblings. Target ids are derived from the scope, so
+ * this runs before focus registration.
+ */
+ public setScopeId(scopeId: UiFocusScopeId): void {
+ this.scopeId_ = scopeId
+ }
+
/**
* Current caller-owned control array.
*/
@@ -190,6 +201,10 @@ namespace ui {
focus: UiFocusState,
scopeOptions?: UiFocusScopeOptions,
): void {
+ control.assert(
+ this.scopeId_ !== undefined,
+ "grid needs a scopeId, or a parent that assigns one",
+ )
const preferred = _uiControls.preferredControlId(
this.scopeId_,
this.controls_,
@@ -208,6 +223,7 @@ namespace ui {
* Registers grid or ragged-grid navigation with a focus input controller.
*/
public registerNavigation(controller: UiFocusInputController): void {
+ if (this.scopeId_ === undefined) return
controller.setNavigation(this.scopeId_, {
kind: "raggedGrid",
rows: this.navigationRows(),
@@ -403,15 +419,17 @@ namespace ui {
this.ensureControlRects()
for (let i = 0; i < this.controls_.length; i++) {
const control = this.controls_[i]
- if (!this.isNavigationControl(control)) continue
const rect = this.controlRects_[i] || new Rect()
+ // Controls that cannot take focus are registered hidden rather
+ // than skipped, so that focus state drops a retained target when
+ // its control is hidden between registrations.
focus.setTarget({
id: _uiControls.targetId(this.scopeId_, control.id),
scopeId: this.scopeId_,
rect,
scrollOwnerId: this.scrollOwnerId_,
scrollRect: this.scrollOwnerId_ ? rect : undefined,
- hidden: !_uiControls.isVisible(control),
+ hidden: !this.isNavigationControl(control),
activatable: true,
})
}
diff --git a/row.ts b/row.ts
index 4e4f07b..5ad62d7 100644
--- a/row.ts
+++ b/row.ts
@@ -25,9 +25,11 @@ namespace ui {
controls: UiRowControl[]
/**
- * Focus scope id for this row.
+ * Focus scope id for this row. Required for a row added to a screen on
+ * its own; omit it for a row placed in a `UiStack` that owns a scope,
+ * which assigns its own.
*/
- scopeId: UiFocusScopeId
+ scopeId?: UiFocusScopeId
/**
* Scroll owner used when this row is arranged in scroll content.
@@ -71,7 +73,7 @@ namespace ui {
/**
* Renders and navigates one horizontal control row.
*/
- export class UiRow implements UiFocusableView> {
+ export class UiRow implements UiComposableFocusView> {
public readonly layoutSpec: UiLayoutSpec
public readonly finalRect: Rect
public layoutDirty: boolean
@@ -119,6 +121,15 @@ namespace ui {
return this.scopeId_
}
+ /**
+ * Adopts an owner scope, so a parent view can navigate this row together
+ * with its siblings. Target ids are derived from the scope, so this runs
+ * before focus registration.
+ */
+ public setScopeId(scopeId: UiFocusScopeId): void {
+ this.scopeId_ = scopeId
+ }
+
/**
* Current caller-owned control array.
*/
@@ -230,6 +241,10 @@ namespace ui {
focus: UiFocusState,
scopeOptions?: UiFocusScopeOptions,
): void {
+ control.assert(
+ this.scopeId_ !== undefined,
+ "row needs a scopeId, or a parent that assigns one",
+ )
const preferred = _uiControls.preferredControlId(
this.scopeId_,
this.controls_,
@@ -244,24 +259,35 @@ namespace ui {
this.ensureControlRects()
for (let i = 0; i < this.controls_.length; i++) {
const control = this.controls_[i]
- if (!this.isNavigationControl(control)) continue
const rect = this.controlRects_[i] || new Rect()
+ // Controls that cannot take focus are registered hidden rather
+ // than skipped, so that focus state drops a retained target when
+ // its control is hidden between registrations.
focus.setTarget({
id: _uiControls.targetId(this.scopeId_, control.id),
scopeId: this.scopeId_,
rect,
scrollOwnerId: this.scrollOwnerId_,
scrollRect: this.scrollOwnerId_ ? rect : undefined,
- hidden: !_uiControls.isVisible(control),
+ hidden: !this.isNavigationControl(control),
activatable: true,
})
}
}
+ /**
+ * Navigation targets as a single row, for parent views that compose
+ * several views into one focus scope.
+ */
+ public navigationRows(): UiFocusNavigationTarget[][] {
+ return [this.navigationTargets()]
+ }
+
/**
* Registers row navigation with a focus input controller.
*/
public registerNavigation(controller: UiFocusInputController): void {
+ if (this.scopeId_ === undefined) return
controller.setNavigation(this.scopeId_, {
kind: "row",
targets: this.navigationTargets(),
diff --git a/samples.ts b/samples.ts
index fcf8c64..61eb156 100644
--- a/samples.ts
+++ b/samples.ts
@@ -2,6 +2,9 @@
// README samples
//-------------------------------------------------
namespace ui.controls.samples {
+ const STACK_SETUP_SCOPE = "stack-setup"
+ const MIXER_SCOPE = "mixer"
+
class SettingsScreen extends ui.UiScreen {
private speed: number
private speedLabel: ui.UiLabel
@@ -169,6 +172,186 @@ namespace ui.controls.samples {
}
}
+ // A column stack that owns one focus scope. Rows of different widths each
+ // center on their own, labels sit between them, and up and down move from
+ // row to row without any navigation code in the screen.
+ class StackSetupScreen extends ui.UiScreen {
+ private status: ui.UiLabel
+
+ constructor(runtime: ui.UiRuntime) {
+ super(runtime)
+ this.backgroundColor = 6
+ this.status = new ui.UiLabel("Choose a size", 15)
+
+ const stack = new ui.UiStack({
+ orientation: "column",
+ // One scope for every row in the stack. The stack registers the
+ // rows under it and answers directional movement itself, which
+ // is what makes up and down cross from one row to the next.
+ scopeId: STACK_SETUP_SCOPE,
+ // Cross-axis placement of each child. Rows of two, three, and
+ // one control all end up on the same center line. A single
+ // child can override it with `alignment` on its record.
+ alignment: "center",
+ wrap: true,
+ gap: 0,
+ children: [
+ // Every child is a record: the view plus whatever spacing
+ // and alignment belong to it rather than to the stack.
+ // Passive views such as labels contribute no focus targets
+ // and are skipped by movement.
+ { view: this.status },
+ { view: new ui.UiLabel("Size", 15), gapBefore: 4 },
+ { view: this.createRow("size", ["S", "M", "L"], 34), gapBefore: 3 },
+ { view: new ui.UiLabel("Speed", 15), gapBefore: 4 },
+ { view: this.createRow("speed", ["Slow", "Fast"], 40), gapBefore: 3 },
+ // For a child pinned to the far edge instead, give the stack
+ // `justify: "spaceBetween"` and a placement that stretches it
+ // along its axis.
+ { view: this.createRow("done", ["Done"], 44), gapBefore: 10 },
+ ],
+ })
+
+ this.add(stack, {
+ x: 0,
+ y: 4,
+ width: ui.STANDARD_DISPLAY_WIDTH,
+ horizontalAlignment: "center",
+ })
+ }
+
+ // Rows built for a stack need no scope of their own. The stack assigns
+ // its scope to each of them, so all the controls navigate as a single
+ // ragged grid.
+ private createRow(
+ id: string,
+ texts: string[],
+ controlWidth: number,
+ ): ui.UiRow {
+ const controls: ui.UiControl[] = []
+ for (let i = 0; i < texts.length; i++) {
+ const text = texts[i]
+ controls.push(
+ ui.button(id + "-" + i, text, () =>
+ this.status.setText(text + " selected"),
+ ),
+ )
+ }
+
+ return new ui.UiRow({
+ controls,
+ controlSize: { width: controlWidth, height: 20 },
+ controlStyle: ui.UiButtonStyles.LightShadowedWhite,
+ gap: 4,
+ })
+ }
+ }
+
+ // The same stack put to work differently: stretched to the display so that
+ // `justify` spreads the children instead of hand-written gaps, one child
+ // aligned against the edge rather than centered, and a grid, a plain button
+ // and a row all sharing the stack's scope. The transport strip is a nested
+ // row stack, which merges its children into a single navigation row.
+ class MixerScreen extends ui.UiScreen {
+ private status: ui.UiLabel
+
+ constructor(runtime: ui.UiRuntime) {
+ super(runtime)
+ this.backgroundColor = 12
+ this.status = new ui.UiLabel("Mixer", 1)
+
+ // A grid contributes one navigation row per grid row, so the pads
+ // navigate internally exactly as they would on their own.
+ const pads = new ui.UiGrid({
+ controls: [
+ this.pad("1"),
+ this.pad("2"),
+ this.pad("3"),
+ this.pad("4"),
+ this.pad("5"),
+ this.pad("6"),
+ ],
+ columnCount: 3,
+ controlSize: { width: 34, height: 20 },
+ controlStyle: ui.UiButtonStyles.LightShadowedWhite,
+ rowGap: 4,
+ columnGap: 4,
+ })
+
+ // A nested stack takes the scope of the stack that holds it, and
+ // passes it on to its own children. Being a row stack, it reports
+ // one merged navigation row, so the button and the pair of volume
+ // controls read as a single strip.
+ const transport = new ui.UiStack({
+ orientation: "row",
+ alignment: "center",
+ gap: 4,
+ children: [
+ {
+ view: new ui.UiButton({
+ id: "play",
+ text: "Play",
+ size: { width: 40, height: 20 },
+ onActivate: () => this.status.setText("Playing"),
+ }),
+ },
+ {
+ view: new ui.UiRow({
+ controls: [
+ // The type argument keeps the control's value
+ // type as `string`. Without it the id is
+ // inferred as its own literal type, which will
+ // not fit a row of `UiControl`.
+ ui.button("volume-down", "-", () =>
+ this.status.setText("Quieter"),
+ ),
+ ui.button("volume-up", "+", () =>
+ this.status.setText("Louder"),
+ ),
+ ],
+ controlSize: { width: 24, height: 20 },
+ controlStyle: ui.UiButtonStyles.LightShadowedWhite,
+ gap: 4,
+ }),
+ },
+ ],
+ })
+
+ const stack = new ui.UiStack({
+ orientation: "column",
+ scopeId: MIXER_SCOPE,
+ alignment: "center",
+ // Spreads the leftover space between the children, which needs a
+ // placement that stretches the stack along its axis. The pads
+ // end up centered and the transport strip pinned to the bottom
+ // without a single hand-tuned gap.
+ justify: "spaceBetween",
+ wrap: true,
+ children: [
+ // Overrides the stack's centering for this child only.
+ { view: this.status, alignment: "start" },
+ { view: pads },
+ { view: transport },
+ ],
+ })
+
+ this.add(stack, {
+ x: 0,
+ y: 4,
+ width: ui.STANDARD_DISPLAY_WIDTH,
+ height: ui.STANDARD_DISPLAY_HEIGHT - 8,
+ horizontalAlignment: "stretch",
+ verticalAlignment: "stretch",
+ })
+ }
+
+ private pad(name: string): ui.UiControl {
+ return ui.button("pad-" + name, name, () =>
+ this.status.setText("Pad " + name),
+ )
+ }
+ }
+
// Selects a locale by assigning the localization seams directly. A
// consuming app normally assigns these from its generated per-language
// file; any code that runs before the UI is constructed can do the same,
@@ -202,7 +385,9 @@ namespace ui.controls.samples {
applyFrenchLocale()
const runtime = new ui.UiRuntime(new ui.DisplayShieldFrameAdapter())
//runtime.push(new SettingsScreen(runtime))
- runtime.push(new NameEntryScreen(runtime))
+ //runtime.push(new NameEntryScreen(runtime))
//runtime.push(new DataGraphScreen(runtime))
+ //runtime.push(new StackSetupScreen(runtime))
+ runtime.push(new MixerScreen(runtime))
runtime.start()
}
diff --git a/stack.ts b/stack.ts
index 4fabfc7..5bee8e3 100644
--- a/stack.ts
+++ b/stack.ts
@@ -4,6 +4,40 @@ namespace ui {
*/
export type UiStackOrientation = "row" | "column"
+ /**
+ * Distribution of leftover space along a stack's own axis. Applies only when
+ * the stack is arranged larger than its content, which needs a parent that
+ * assigns a size, such as a placement with `stretch` on that axis.
+ */
+ export type UiStackJustify = "start" | "center" | "end" | "spaceBetween"
+
+ /**
+ * Child view with stack-local placement options.
+ */
+ export interface UiStackChild {
+ /**
+ * View arranged and rendered by the stack.
+ */
+ view: UiView
+
+ /**
+ * Cross-axis placement for this child. Omitted values use the stack's
+ * alignment.
+ */
+ alignment?: UiLayoutAlignment
+
+ /**
+ * Extra space inserted before this child. Added to the stack gap for
+ * non-first children.
+ */
+ gapBefore?: number
+
+ /**
+ * Extra space inserted after this child.
+ */
+ gapAfter?: number
+ }
+
/**
* Options for a stack of views.
*/
@@ -14,9 +48,9 @@ namespace ui {
orientation: UiStackOrientation
/**
- * Child views in arrangement order.
+ * Child records in arrangement order.
*/
- children: UiView[]
+ children: UiStackChild[]
/**
* Space between adjacent children.
@@ -24,51 +58,114 @@ namespace ui {
gap?: number
/**
- * Cross-axis placement of each child.
+ * Cross-axis placement of each child. Individual children may override
+ * it.
*/
alignment?: UiLayoutAlignment
+
+ /**
+ * Distribution of leftover space along the stack's axis.
+ */
+ justify?: UiStackJustify
+
+ /**
+ * Focus scope owned by this stack. When set, the stack registers its
+ * focusable children under this one scope and navigates them together;
+ * children keep rendering and arranging themselves. Omit it for a
+ * layout-only stack.
+ */
+ scopeId?: UiFocusScopeId
+
+ /**
+ * Whether movement along a row wraps from the last target to the first
+ * and back.
+ */
+ wrap?: boolean
+
+ /**
+ * Strategy used when moving between rows of a column stack. Defaults to
+ * `"nearest"`, which suits rows of differing widths or alignments.
+ */
+ verticalStrategy?: UiFocusVerticalStrategy
}
/**
* Arranges and renders child views along one axis.
+ *
+ * Given a `scopeId`, the stack also owns one focus scope for its children:
+ * it registers their targets under that scope and answers directional
+ * movement across all of them, so a column of rows navigates as one ragged
+ * grid and passive children such as labels are simply skipped.
*/
- export class UiStack implements UiView {
+ export class UiStack
+ implements UiComposableFocusView, UiFocusNavigationProvider
+ {
public readonly layoutSpec: UiLayoutSpec
public readonly finalRect: Rect
public layoutDirty: boolean
private orientation_: UiStackOrientation
- private children_: UiView[]
+ private children_: UiStackChild[]
private gap_: number
private alignment_: UiLayoutAlignment
+ private justify_: UiStackJustify
+ private scopeId_: UiFocusScopeId
+ private wrap_: boolean
+ private verticalStrategy_: UiFocusVerticalStrategy
private childRect_: Rect
private childSize_: UiMeasuredSize
private childConstraints_: UiLayoutConstraints
constructor(options: UiStackOptions) {
this.orientation_ = options.orientation
- this.children_ = options.children || []
this.gap_ = _uiControls.gap(options.gap)
this.alignment_ = options.alignment || "start"
+ this.justify_ = options.justify || "start"
+ this.scopeId_ = options.scopeId
+ this.wrap_ = !!options.wrap
+ this.verticalStrategy_ = options.verticalStrategy || "nearest"
this.layoutSpec = _uiControls.defaultLayoutSpec()
this.finalRect = new Rect()
this.layoutDirty = true
this.childRect_ = new Rect()
this.childSize_ = new UiMeasuredSize()
this.childConstraints_ = { maxWidth: 0, maxHeight: 0 }
+ this.children_ = []
+ this.setChildren(options.children)
+ }
+
+ /**
+ * Focus scope owned by this stack, or `undefined` for a layout-only
+ * stack.
+ */
+ public get scopeId(): UiFocusScopeId {
+ return this.scopeId_
+ }
+
+ /**
+ * Adopts an owner scope and passes it on to the children, so a stack
+ * nests inside another stack's scope.
+ */
+ public setScopeId(scopeId: UiFocusScopeId): void {
+ this.scopeId_ = scopeId
+ for (let i = 0; i < this.children_.length; i++)
+ this.adoptScope(this.children_[i])
}
/**
- * Current child views.
+ * Current child records in arrangement order.
*/
- public get children(): UiView[] {
+ public get children(): UiStackChild[] {
return this.children_
}
/**
- * Replaces the child views and marks layout dirty.
+ * Replaces the child records and marks layout dirty. Children that can
+ * join a focus scope adopt this stack's scope.
*/
- public setChildren(children: UiView[]): void {
+ public setChildren(children: UiStackChild[]): void {
this.children_ = children || []
+ for (let i = 0; i < this.children_.length; i++)
+ this.adoptScope(this.children_[i])
this.invalidateLayout()
}
@@ -83,18 +180,17 @@ namespace ui {
let mainSize = 0
let crossSize = 0
for (let i = 0; i < this.children_.length; i++) {
- this.children_[i].measure(constraints, this.childSize_)
+ const child = this.children_[i]
+ child.view.measure(constraints, this.childSize_)
const main = row
? this.childSize_.preferredWidth
: this.childSize_.preferredHeight
const cross = row
? this.childSize_.preferredHeight
: this.childSize_.preferredWidth
- mainSize += main
+ mainSize += main + this.gapBefore(i) + this.gapAfter(i)
if (cross > crossSize) crossSize = cross
}
- if (this.children_.length > 1)
- mainSize += this.gap_ * (this.children_.length - 1)
const width = row ? mainSize : crossSize
const height = row ? crossSize : mainSize
measureLayoutSpec(
@@ -117,17 +213,24 @@ namespace ui {
this.childConstraints_.maxWidth = this.finalRect.width
this.childConstraints_.maxHeight = this.finalRect.height
const row = this.orientation_ == "row"
- let pos = row ? this.finalRect.x : this.finalRect.y
+ const available = row ? this.finalRect.width : this.finalRect.height
+ const content = this.contentMainSize()
+ const slack = Math.max(0, available - content)
+ let pos = (row ? this.finalRect.x : this.finalRect.y) +
+ this.justifyOffset(slack)
+ const spacing = this.justifySpacing(slack)
for (let i = 0; i < this.children_.length; i++) {
const child = this.children_[i]
- child.measure(this.childConstraints_, this.childSize_)
+ const alignment = child.alignment || this.alignment_
+ child.view.measure(this.childConstraints_, this.childSize_)
const childWidth = this.childSize_.preferredWidth
const childHeight = this.childSize_.preferredHeight
+ pos += this.gapBefore(i) + (i ? spacing : 0)
if (row) {
const h = _uiLayout.alignedSize(
this.finalRect.height,
childHeight,
- this.alignment_,
+ alignment,
)
this.childRect_.set(
pos,
@@ -135,32 +238,32 @@ namespace ui {
this.finalRect.y,
this.finalRect.height,
h,
- this.alignment_,
+ alignment,
),
childWidth,
h,
)
- pos += childWidth + this.gap_
+ pos += childWidth + this.gapAfter(i)
} else {
const w = _uiLayout.alignedSize(
this.finalRect.width,
childWidth,
- this.alignment_,
+ alignment,
)
this.childRect_.set(
_uiLayout.alignedOffset(
this.finalRect.x,
this.finalRect.width,
w,
- this.alignment_,
+ alignment,
),
pos,
w,
childHeight,
)
- pos += childHeight + this.gap_
+ pos += childHeight + this.gapAfter(i)
}
- child.arrange(this.childRect_)
+ child.view.arrange(this.childRect_)
}
this.clearLayoutInvalidation()
}
@@ -179,6 +282,105 @@ namespace ui {
this.layoutDirty = false
}
+ /**
+ * Registers the scope this stack owns and its children's targets under
+ * it. Layout-only stacks register nothing.
+ */
+ public registerFocusTargets(
+ focus: UiFocusState,
+ scopeOptions?: UiFocusScopeOptions,
+ ): void {
+ if (this.scopeId_ === undefined) return
+ const scope = scopeOptions || {
+ id: this.scopeId_,
+ preferredTargetId: this.resolvePreferredTargetId(),
+ }
+ focus.setScope(scope)
+ for (let i = 0; i < this.children_.length; i++) {
+ const child = this.focusChild(i)
+ if (child) child.registerFocusTargets(focus, scope)
+ }
+ }
+
+ /**
+ * Registers this stack as the navigation for the scope it owns, so that
+ * movement is answered from current child state on every press.
+ */
+ public registerNavigation(controller: UiFocusInputController): void {
+ if (this.scopeId_ === undefined) return
+ controller.setNavigation(this.scopeId_, this)
+ }
+
+ /**
+ * Focuses this stack's retained or preferred target.
+ */
+ public focusDefault(focus: UiFocusState): UiFocusSetResult {
+ if (this.scopeId_ === undefined)
+ return { kind: "rejected", reason: "missingScope" }
+ return focus.setActiveScope(this.scopeId_)
+ }
+
+ /**
+ * Rows of navigation targets across the focusable children. A column
+ * stack contributes each child's rows in order; a row stack merges them
+ * into one row.
+ */
+ public navigationRows(): UiFocusNavigationTarget[][] {
+ const rows: UiFocusNavigationTarget[][] = []
+ for (let i = 0; i < this.children_.length; i++) {
+ const child = this.focusChild(i)
+ if (!child) continue
+ const childRows = child.navigationRows()
+ for (let j = 0; j < childRows.length; j++)
+ rows.push(childRows[j])
+ }
+ if (this.orientation_ != "row") return rows
+
+ const merged: UiFocusNavigationTarget[] = []
+ for (let i = 0; i < rows.length; i++)
+ for (let j = 0; j < rows[i].length; j++) merged.push(rows[i][j])
+ return [merged]
+ }
+
+ /**
+ * Returns the target this stack focuses by default, which is the first
+ * one offered by a focusable child.
+ */
+ public resolvePreferredTargetId(): UiFocusId | undefined {
+ for (let i = 0; i < this.children_.length; i++) {
+ const child = this.focusChild(i)
+ if (!child) continue
+ const targetId = child.resolvePreferredTargetId()
+ if (targetId !== undefined) return targetId
+ }
+ return undefined
+ }
+
+ /**
+ * Moves focus within the scope this stack owns.
+ */
+ public move(request: UiFocusNavigationRequest): UiFocusMoveResult {
+ if (
+ this.scopeId_ === undefined ||
+ request.scopeId != this.scopeId_
+ )
+ return undefined
+ const rows = this.navigationRows()
+ const result = moveFocusInRaggedGrid({
+ scopeId: this.scopeId_,
+ currentTargetId: request.currentTargetId,
+ direction: request.direction,
+ horizontalWrap: this.wrap_,
+ verticalStrategy: this.verticalStrategy_,
+ rows,
+ })
+ // Focus was left on a control that has since been hidden, so no
+ // movement can start from it. Recover onto the first target.
+ if (result.kind == "stayed" && result.reason == "missingActive")
+ return this.moveToFirstTarget(rows, request)
+ return result
+ }
+
/**
* Renders the child views.
*/
@@ -188,14 +390,21 @@ namespace ui {
focus?: UiFocusState,
): void {
for (let i = 0; i < this.children_.length; i++) {
- this.children_[i].render(surface, assets, focus)
+ this.children_[i].view.render(surface, assets, focus)
}
}
/**
- * Stacks do not consume focus input.
+ * Forwards focus input to the children, so controls inside a stack
+ * activate as they do when added to a screen directly.
*/
- public handleFocusInput(result: UiFocusInputResult): undefined {
+ public handleFocusInput(result: UiFocusInputResult): any {
+ for (let i = 0; i < this.children_.length; i++) {
+ const child = this.focusChild(i)
+ if (!child) continue
+ const childResult = child.handleFocusInput(result)
+ if (childResult) return childResult
+ }
return undefined
}
@@ -204,10 +413,90 @@ namespace ui {
*/
public _resolveContentAssets(assets: UiAssetResolver): void {
for (let i = 0; i < this.children_.length; i++) {
- const child = this.children_[i]
+ const child = this.children_[i].view
if (child._resolveContentAssets)
child._resolveContentAssets(assets)
}
}
+
+ private adoptScope(child: UiStackChild): void {
+ if (this.scopeId_ === undefined) return
+ const focusable = child.view
+ if (focusable.setScopeId) focusable.setScopeId(this.scopeId_)
+ }
+
+ private focusChild(index: number): UiComposableFocusView {
+ const child = this.children_[index].view
+ if (!child.navigationRows || !child.registerFocusTargets)
+ return undefined
+ return >child
+ }
+
+ private moveToFirstTarget(
+ rows: UiFocusNavigationTarget[][],
+ request: UiFocusNavigationRequest,
+ ): UiFocusMoveResult {
+ for (let i = 0; i < rows.length; i++) {
+ for (let j = 0; j < rows[i].length; j++) {
+ if (rows[i][j].hidden) continue
+ return {
+ kind: "moved",
+ fromScopeId: this.scopeId_,
+ fromTargetId: request.currentTargetId,
+ toScopeId: this.scopeId_,
+ toTargetId: rows[i][j].id,
+ }
+ }
+ }
+ return {
+ kind: "stayed",
+ scopeId: this.scopeId_,
+ targetId: request.currentTargetId,
+ reason: "empty",
+ }
+ }
+
+ private contentMainSize(): number {
+ const row = this.orientation_ == "row"
+ let mainSize = 0
+ for (let i = 0; i < this.children_.length; i++) {
+ this.children_[i].view.measure(
+ this.childConstraints_,
+ this.childSize_,
+ )
+ mainSize += row
+ ? this.childSize_.preferredWidth
+ : this.childSize_.preferredHeight
+ mainSize += this.gapBefore(i) + this.gapAfter(i)
+ }
+ return mainSize
+ }
+
+ private justifyOffset(slack: number): number {
+ if (this.justify_ == "center") return slack >> 1
+ if (this.justify_ == "end") return slack
+ return 0
+ }
+
+ private justifySpacing(slack: number): number {
+ if (this.justify_ != "spaceBetween") return 0
+ if (this.children_.length < 2) return 0
+ return Math.idiv(slack, this.children_.length - 1)
+ }
+
+ private gapBefore(index: number): number {
+ const gap = index ? this.gap_ : 0
+ return (
+ gap +
+ _uiControls.sanitizeDimension(this.children_[index].gapBefore, 0)
+ )
+ }
+
+ private gapAfter(index: number): number {
+ return _uiControls.sanitizeDimension(
+ this.children_[index].gapAfter,
+ 0,
+ )
+ }
}
}
From 610eba7250d920e038b5bcb959af8cefbe6edc78 Mon Sep 17 00:00:00 2001
From: Eric Anderson
Date: Sun, 2 Aug 2026 22:00:20 -0700
Subject: [PATCH 2/8] feat: render focus after roots
---
button.ts | 33 +++++++++++++++++++++++++++------
stack.ts | 36 ++++++++++++++++++++++++++++++++++--
2 files changed, 61 insertions(+), 8 deletions(-)
diff --git a/button.ts b/button.ts
index dd07ca7..6501b6a 100644
--- a/button.ts
+++ b/button.ts
@@ -782,22 +782,43 @@ namespace ui {
surface: DrawSurface,
assets: UiAssetResolver,
focus?: UiFocusState,
+ ): void {
+ this.renderControls(surface, assets, focus)
+ this.renderFocus(surface, assets, focus)
+ }
+
+ /**
+ * Renders the button without its focused overlay.
+ */
+ public renderControls(
+ surface: DrawSurface,
+ assets: UiAssetResolver,
+ focus?: UiFocusState,
): void {
if (!_uiControls.isVisible(this.control_)) return
- const labelBounds = _uiControls.resolveLabelBounds(
- surface,
- this.labelBounds_,
- )
_uiControls.renderControl(
surface,
this.control_,
this.finalRect,
this.controlView_,
undefined,
- labelBounds,
+ _uiControls.resolveLabelBounds(surface, this.labelBounds_),
undefined,
assets,
)
+ }
+
+ /**
+ * Renders only this button's focus treatment, which a parent draws in a
+ * later pass so that the focus label is not covered by a view rendered
+ * after it.
+ */
+ public renderFocus(
+ surface: DrawSurface,
+ assets: UiAssetResolver,
+ focus?: UiFocusState,
+ ): void {
+ if (!_uiControls.isVisible(this.control_)) return
if (
_uiControls.activeTargetIdForScope(focus, this.scopeId_) ==
this.targetId()
@@ -808,7 +829,7 @@ namespace ui {
this.finalRect,
this.controlView_,
undefined,
- labelBounds,
+ _uiControls.resolveLabelBounds(surface, this.labelBounds_),
true,
assets,
)
diff --git a/stack.ts b/stack.ts
index 5bee8e3..14b50d2 100644
--- a/stack.ts
+++ b/stack.ts
@@ -382,15 +382,47 @@ namespace ui {
}
/**
- * Renders the child views.
+ * Renders the child views, with every focus treatment drawn after every
+ * control.
*/
public render(
surface: DrawSurface,
assets: UiAssetResolver,
focus?: UiFocusState,
+ ): void {
+ this.renderControls(surface, assets, focus)
+ this.renderFocus(surface, assets, focus)
+ }
+
+ /**
+ * Renders the child views without their focus treatments. Children that
+ * cannot separate the two render whole.
+ */
+ public renderControls(
+ surface: DrawSurface,
+ assets: UiAssetResolver,
+ focus?: UiFocusState,
+ ): void {
+ for (let i = 0; i < this.children_.length; i++) {
+ const view = this.children_[i].view
+ if (view.renderControls) view.renderControls(surface, assets, focus)
+ else view.render(surface, assets, focus)
+ }
+ }
+
+ /**
+ * Renders only the children's focus treatments. Drawing these in a pass
+ * of their own keeps a focus label, which extends past its control, from
+ * being covered by a child rendered after it.
+ */
+ public renderFocus(
+ surface: DrawSurface,
+ assets: UiAssetResolver,
+ focus?: UiFocusState,
): void {
for (let i = 0; i < this.children_.length; i++) {
- this.children_[i].view.render(surface, assets, focus)
+ const view = this.children_[i].view
+ if (view.renderFocus) view.renderFocus(surface, assets, focus)
}
}
From a37ff3341a7eb020b1b334e3a1b95af5c1d1394b Mon Sep 17 00:00:00 2001
From: Eric Anderson
Date: Sun, 2 Aug 2026 22:25:51 -0700
Subject: [PATCH 3/8] bump ui-core -> 0.0.8
---
pxt.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/pxt.json b/pxt.json
index c4e1a8f..82bf8c6 100644
--- a/pxt.json
+++ b/pxt.json
@@ -31,7 +31,7 @@
"radio": "*",
"microphone": "*",
"display-shield": "github:microbit-apps/display-shield#v1.1.4",
- "ui-core": "github:microbit-apps/ui-core#v0.0.7"
+ "ui-core": "github:microbit-apps/ui-core#v0.0.8"
},
"testDependencies": {}
}
From d9f1eda97b57eb9f1fb9aa81fa3c904bc5ed2a02 Mon Sep 17 00:00:00 2001
From: Eric Anderson
Date: Sun, 2 Aug 2026 22:54:37 -0700
Subject: [PATCH 4/8] addressing pr feedback
---
grid.ts | 9 ++++++---
row.ts | 9 ++++++---
stack.ts | 8 +++++---
3 files changed, 17 insertions(+), 9 deletions(-)
diff --git a/grid.ts b/grid.ts
index 283f1a0..21fb86d 100644
--- a/grid.ts
+++ b/grid.ts
@@ -52,7 +52,7 @@ namespace ui {
public readonly layoutSpec: UiLayoutSpec
public readonly finalRect: Rect
public layoutDirty: boolean
- private scopeId_: UiFocusScopeId
+ private scopeId_: UiFocusScopeId | undefined
private controls_: UiControl[]
private defaultControlId_: string
private scrollOwnerId_: UiFocusScrollOwnerId
@@ -95,7 +95,7 @@ namespace ui {
/**
* Focus scope id used by this grid.
*/
- public get scopeId(): UiFocusScopeId {
+ public get scopeId(): UiFocusScopeId | undefined {
return this.scopeId_
}
@@ -232,9 +232,12 @@ namespace ui {
}
/**
- * Focuses the grid's retained, default, or first enabled control.
+ * Focuses the grid's retained, default, or first enabled control. A grid
+ * waiting for a parent to assign it a scope has nothing to focus.
*/
public focusDefault(focus: UiFocusState): UiFocusSetResult {
+ if (this.scopeId_ === undefined)
+ return { kind: "rejected", reason: "missingScope" }
return focus.setActiveScope(this.scopeId_)
}
diff --git a/row.ts b/row.ts
index 5ad62d7..14a687d 100644
--- a/row.ts
+++ b/row.ts
@@ -77,7 +77,7 @@ namespace ui {
public readonly layoutSpec: UiLayoutSpec
public readonly finalRect: Rect
public layoutDirty: boolean
- private scopeId_: UiFocusScopeId
+ private scopeId_: UiFocusScopeId | undefined
private controls_: UiRowControl[]
private defaultControlId_: string
private scrollOwnerId_: UiFocusScrollOwnerId
@@ -117,7 +117,7 @@ namespace ui {
/**
* Focus scope id used by this row.
*/
- public get scopeId(): UiFocusScopeId {
+ public get scopeId(): UiFocusScopeId | undefined {
return this.scopeId_
}
@@ -296,9 +296,12 @@ namespace ui {
}
/**
- * Focuses the row's retained, default, or first enabled control.
+ * Focuses the row's retained, default, or first enabled control. A row
+ * waiting for a parent to assign it a scope has nothing to focus.
*/
public focusDefault(focus: UiFocusState): UiFocusSetResult {
+ if (this.scopeId_ === undefined)
+ return { kind: "rejected", reason: "missingScope" }
return focus.setActiveScope(this.scopeId_)
}
diff --git a/stack.ts b/stack.ts
index 14b50d2..4eb55c4 100644
--- a/stack.ts
+++ b/stack.ts
@@ -108,7 +108,7 @@ namespace ui {
private gap_: number
private alignment_: UiLayoutAlignment
private justify_: UiStackJustify
- private scopeId_: UiFocusScopeId
+ private scopeId_: UiFocusScopeId | undefined
private wrap_: boolean
private verticalStrategy_: UiFocusVerticalStrategy
private childRect_: Rect
@@ -137,7 +137,7 @@ namespace ui {
* Focus scope owned by this stack, or `undefined` for a layout-only
* stack.
*/
- public get scopeId(): UiFocusScopeId {
+ public get scopeId(): UiFocusScopeId | undefined {
return this.scopeId_
}
@@ -457,7 +457,9 @@ namespace ui {
if (focusable.setScopeId) focusable.setScopeId(this.scopeId_)
}
- private focusChild(index: number): UiComposableFocusView {
+ private focusChild(
+ index: number,
+ ): UiComposableFocusView | undefined {
const child = this.children_[index].view
if (!child.navigationRows || !child.registerFocusTargets)
return undefined
From 2003a26a197d002dfbcafcbbaa9891a9d0e6ae43 Mon Sep 17 00:00:00 2001
From: Eric Anderson
Date: Sun, 2 Aug 2026 23:10:19 -0700
Subject: [PATCH 5/8] let nested scopes be registered
---
stack.ts | 53 ++++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 42 insertions(+), 11 deletions(-)
diff --git a/stack.ts b/stack.ts
index 4eb55c4..cbcd106 100644
--- a/stack.ts
+++ b/stack.ts
@@ -284,13 +284,23 @@ namespace ui {
/**
* Registers the scope this stack owns and its children's targets under
- * it. Layout-only stacks register nothing.
+ * it. A stack that owns no scope steps aside and lets each child
+ * register the scope it came with, so a focusable view placed in a
+ * layout-only stack behaves as it would on a screen of its own.
*/
public registerFocusTargets(
focus: UiFocusState,
scopeOptions?: UiFocusScopeOptions,
): void {
- if (this.scopeId_ === undefined) return
+ if (this.scopeId_ === undefined) {
+ for (let i = 0; i < this.children_.length; i++) {
+ const view = this.children_[i].view
+ if (view.registerFocusTargets)
+ view.registerFocusTargets(focus)
+ }
+ return
+ }
+
const scope = scopeOptions || {
id: this.scopeId_,
preferredTargetId: this.resolvePreferredTargetId(),
@@ -304,20 +314,39 @@ namespace ui {
/**
* Registers this stack as the navigation for the scope it owns, so that
- * movement is answered from current child state on every press.
+ * movement is answered from current child state on every press. A stack
+ * that owns no scope lets each child register its own navigation.
*/
public registerNavigation(controller: UiFocusInputController): void {
- if (this.scopeId_ === undefined) return
+ if (this.scopeId_ === undefined) {
+ for (let i = 0; i < this.children_.length; i++) {
+ const view = this.children_[i].view
+ if (view.registerNavigation) view.registerNavigation(controller)
+ }
+ return
+ }
+
controller.setNavigation(this.scopeId_, this)
}
/**
- * Focuses this stack's retained or preferred target.
+ * Focuses this stack's retained or preferred target. A stack that owns
+ * no scope offers its children in order, so that focus still starts
+ * somewhere sensible inside a layout-only stack.
*/
public focusDefault(focus: UiFocusState): UiFocusSetResult {
- if (this.scopeId_ === undefined)
- return { kind: "rejected", reason: "missingScope" }
- return focus.setActiveScope(this.scopeId_)
+ if (this.scopeId_ !== undefined)
+ return focus.setActiveScope(this.scopeId_)
+
+ let firstResult: UiFocusSetResult = undefined
+ for (let i = 0; i < this.children_.length; i++) {
+ const view = this.children_[i].view
+ if (!view.focusDefault) continue
+ const result = view.focusDefault(focus)
+ if (result && result.kind == "focused") return result
+ if (!firstResult) firstResult = result
+ }
+ return firstResult || { kind: "rejected", reason: "missingScope" }
}
/**
@@ -432,9 +461,11 @@ namespace ui {
*/
public handleFocusInput(result: UiFocusInputResult): any {
for (let i = 0; i < this.children_.length; i++) {
- const child = this.focusChild(i)
- if (!child) continue
- const childResult = child.handleFocusInput(result)
+ // Every child is offered the result, not just the ones this
+ // stack navigates, so a child holding a scope of its own still
+ // activates.
+ const childResult =
+ this.children_[i].view.handleFocusInput(result)
if (childResult) return childResult
}
return undefined
From 052dbe3fa8e1b1176acd6f074009c1b5732996dd Mon Sep 17 00:00:00 2001
From: Eric Anderson
Date: Sun, 2 Aug 2026 23:43:35 -0700
Subject: [PATCH 6/8] add guards
---
grid.ts | 3 +++
row.ts | 3 +++
stack.ts | 7 ++++---
3 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/grid.ts b/grid.ts
index 21fb86d..a91e455 100644
--- a/grid.ts
+++ b/grid.ts
@@ -245,6 +245,9 @@ namespace ui {
* Returns the target id chosen by default-control rules.
*/
public resolvePreferredTargetId(): UiFocusId | undefined {
+ // Target ids are built from the scope, so a grid still waiting for a
+ // parent to assign one has no target to offer.
+ if (this.scopeId_ === undefined) return undefined
return _uiControls.preferredControlId(
this.scopeId_,
this.controls_,
diff --git a/row.ts b/row.ts
index 14a687d..d1d858b 100644
--- a/row.ts
+++ b/row.ts
@@ -309,6 +309,9 @@ namespace ui {
* Returns the target id chosen by default-control rules.
*/
public resolvePreferredTargetId(): UiFocusId | undefined {
+ // Target ids are built from the scope, so a row still waiting for a
+ // parent to assign one has no target to offer.
+ if (this.scopeId_ === undefined) return undefined
return _uiControls.preferredControlId(
this.scopeId_,
this.controls_,
diff --git a/stack.ts b/stack.ts
index cbcd106..f6a7d89 100644
--- a/stack.ts
+++ b/stack.ts
@@ -48,9 +48,10 @@ namespace ui {
orientation: UiStackOrientation
/**
- * Child records in arrangement order.
+ * Child records in arrangement order. Omitted for a stack whose
+ * children are set later with `setChildren`.
*/
- children: UiStackChild[]
+ children?: UiStackChild[]
/**
* Space between adjacent children.
@@ -162,7 +163,7 @@ namespace ui {
* Replaces the child records and marks layout dirty. Children that can
* join a focus scope adopt this stack's scope.
*/
- public setChildren(children: UiStackChild[]): void {
+ public setChildren(children?: UiStackChild[]): void {
this.children_ = children || []
for (let i = 0; i < this.children_.length; i++)
this.adoptScope(this.children_[i])
From f32fd6552ca4d9e8b12559717631acb6a50a1d8b Mon Sep 17 00:00:00 2001
From: Eric Anderson
Date: Mon, 3 Aug 2026 00:12:20 -0700
Subject: [PATCH 7/8] addressing copilot feedback
---
stack.ts | 25 ++++++--
test.ts | 170 +++++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 191 insertions(+), 4 deletions(-)
diff --git a/stack.ts b/stack.ts
index f6a7d89..ea7b4b0 100644
--- a/stack.ts
+++ b/stack.ts
@@ -214,9 +214,18 @@ namespace ui {
this.childConstraints_.maxWidth = this.finalRect.width
this.childConstraints_.maxHeight = this.finalRect.height
const row = this.orientation_ == "row"
- const available = row ? this.finalRect.width : this.finalRect.height
- const content = this.contentMainSize()
- const slack = Math.max(0, available - content)
+ // Only a stack that distributes leftover space needs the content
+ // size, and measuring for it costs a pass over every child. Packing
+ // from the start needs neither.
+ const slack =
+ this.justify_ == "start"
+ ? 0
+ : Math.max(
+ 0,
+ (row
+ ? this.finalRect.width
+ : this.finalRect.height) - this.contentMainSize(),
+ )
let pos = (row ? this.finalRect.x : this.finalRect.y) +
this.justifyOffset(slack)
const spacing = this.justifySpacing(slack)
@@ -493,7 +502,15 @@ namespace ui {
index: number,
): UiComposableFocusView | undefined {
const child = this.children_[index].view
- if (!child.navigationRows || !child.registerFocusTargets)
+ // The whole composable surface is required, since this stack calls
+ // every part of it. A view offering only some of it is left to
+ // itself rather than half driven.
+ if (
+ !child.navigationRows ||
+ !child.registerFocusTargets ||
+ !child.resolvePreferredTargetId ||
+ !child.setScopeId
+ )
return undefined
return >child
}
diff --git a/test.ts b/test.ts
index ae65a92..dd6bedb 100644
--- a/test.ts
+++ b/test.ts
@@ -2334,6 +2334,175 @@ namespace ui.controls.test {
* unchanged, that an assigned catalog replaces a rendered caption, and that
* an assigned default font drives control measurement.
*/
+ function stackSmokeControls(
+ prefix: string,
+ count: number,
+ ): UiControl[] {
+ const controls: UiControl[] = []
+ for (let i = 0; i < count; i++)
+ controls.push(button(prefix + i, prefix + i, () => {}))
+ return controls
+ }
+
+ export function runStackCompositionSmokeTest(): void {
+ const runtime = new UiRuntime(
+ new RuntimeSmokeDisplayAdapter(() => {}),
+ new ControlSmokeAssets(),
+ )
+ const screen = new ControlSmokeScreen(runtime, () => undefined)
+
+ // A row, a two-row grid and a button, composed into one scope owned by
+ // the stack. The label contributes no targets and is skipped.
+ const rowControls = stackSmokeControls("r", 3)
+ const gridControls = stackSmokeControls("g", 4)
+ const row = new UiRow({
+ controls: rowControls,
+ controlSize: { width: 30, height: 16 },
+ gap: 4,
+ })
+ const grid = new UiGrid({
+ controls: gridControls,
+ columnCount: 2,
+ controlSize: { width: 30, height: 16 },
+ rowGap: 4,
+ columnGap: 4,
+ })
+ const done = new UiButton({
+ id: "done",
+ text: "Done",
+ size: { width: 40, height: 16 },
+ })
+ const stack = new UiStack({
+ orientation: "column",
+ scopeId: "stack",
+ alignment: "center",
+ wrap: true,
+ gap: 4,
+ children: [
+ { view: new UiLabel("Header", 1) },
+ { view: row },
+ { view: grid },
+ { view: done },
+ ],
+ })
+ screen.add(stack, {
+ x: 0,
+ y: 0,
+ width: STANDARD_DISPLAY_WIDTH,
+ horizontalAlignment: "center",
+ })
+ screen._enter()
+
+ control.assert(
+ row.scopeId == "stack" && grid.scopeId == "stack",
+ "stack assigns its scope to composed children",
+ )
+ control.assert(
+ screen.focus.getActiveTargetId("stack") == "stack/r0",
+ "stack focuses the first target of its first focusable child",
+ )
+
+ // Down crosses from the row into the grid, through the grid's own rows,
+ // and on into the button, landing nearest the control it came from.
+ control.assert(screen.routeInput({ action: "down" }), "stack down row")
+ control.assert(
+ screen.focus.getActiveTargetId("stack") == "stack/g0",
+ "stack down enters the grid nearest the source",
+ )
+ control.assert(screen.routeInput({ action: "down" }), "stack down grid")
+ control.assert(
+ screen.focus.getActiveTargetId("stack") == "stack/g2",
+ "stack down moves within the grid",
+ )
+ control.assert(
+ screen.routeInput({ action: "down" }),
+ "stack down button",
+ )
+ control.assert(
+ screen.focus.getActiveTargetId("stack") == "stack/done",
+ "stack down reaches the button",
+ )
+ control.assert(
+ !screen.routeInput({ action: "down" }),
+ "stack down past the last child is unhandled",
+ )
+ control.assert(screen.routeInput({ action: "up" }), "stack up button")
+ control.assert(
+ screen.focus.getActiveTargetId("stack") == "stack/g2",
+ "stack up returns to the grid",
+ )
+
+ // Wrapping stays inside the child that owns the row of targets.
+ screen.focus.setActiveTarget("stack", "stack/r0")
+ control.assert(screen.routeInput({ action: "left" }), "stack wrap left")
+ control.assert(
+ screen.focus.getActiveTargetId("stack") == "stack/r2",
+ "stack left wraps within the row",
+ )
+
+ control.assert(
+ screen.routeInput({ action: "activate" }),
+ "stack forwards activation to a composed child",
+ )
+
+ // A child whose controls are all hidden is stepped over.
+ for (let i = 0; i < gridControls.length; i++)
+ gridControls[i].visible = false
+ stack.registerFocusTargets(screen.focus)
+ screen.focus.setActiveTarget("stack", "stack/r1")
+ control.assert(
+ screen.routeInput({ action: "down" }),
+ "stack down past hidden child",
+ )
+ control.assert(
+ screen.focus.getActiveTargetId("stack") == "stack/done",
+ "stack skips a child with no visible targets",
+ )
+
+ // A stack with no scope of its own leaves its children to the scopes
+ // they came with, rather than swallowing their registration.
+ const layoutRuntime = new UiRuntime(
+ new RuntimeSmokeDisplayAdapter(() => {}),
+ new ControlSmokeAssets(),
+ )
+ const layoutScreen = new ControlSmokeScreen(
+ layoutRuntime,
+ () => undefined,
+ )
+ const ownRow = new UiRow({
+ scopeId: "own-row",
+ controls: stackSmokeControls("o", 2),
+ controlSize: { width: 30, height: 16 },
+ gap: 4,
+ wrap: true,
+ })
+ layoutScreen.add(
+ new UiStack({
+ orientation: "column",
+ children: [{ view: new UiLabel("Plain", 1) }, { view: ownRow }],
+ gap: 4,
+ }),
+ { x: 0, y: 0, width: STANDARD_DISPLAY_WIDTH },
+ )
+ layoutScreen._enter()
+ control.assert(
+ ownRow.scopeId == "own-row",
+ "layout-only stack leaves a child's scope alone",
+ )
+ control.assert(
+ layoutScreen.focus.getActiveTargetId("own-row") == "own-row/o0",
+ "layout-only stack focuses a child that owns its scope",
+ )
+ control.assert(
+ layoutScreen.routeInput({ action: "right" }),
+ "layout-only stack child navigates itself",
+ )
+ control.assert(
+ layoutScreen.focus.getActiveTargetId("own-row") == "own-row/o1",
+ "layout-only stack forwards child navigation",
+ )
+ }
+
export function runLocalizationSmokeTest(): void {
// Baseline: no catalog and no default font means identity behavior.
_loc.table = undefined
@@ -2414,6 +2583,7 @@ namespace ui.controls.test {
runTextEntrySmokeTest()
runTextEntryCharsetSmokeTest()
runTextEntryAccentsSmokeTest()
+ runStackCompositionSmokeTest()
runLocalizationSmokeTest()
control.__log(1, "All tests passed!")
From b3ca42f19eaa7e54ccc6e84dd24a2bd85ab13706 Mon Sep 17 00:00:00 2001
From: Eric Anderson
Date: Mon, 3 Aug 2026 00:31:37 -0700
Subject: [PATCH 8/8] fix: revert earlier widening, and assert
---
stack.ts | 22 +++++++++++++---------
1 file changed, 13 insertions(+), 9 deletions(-)
diff --git a/stack.ts b/stack.ts
index ea7b4b0..48461eb 100644
--- a/stack.ts
+++ b/stack.ts
@@ -502,16 +502,20 @@ namespace ui {
index: number,
): UiComposableFocusView | undefined {
const child = this.children_[index].view
- // The whole composable surface is required, since this stack calls
- // every part of it. A view offering only some of it is left to
- // itself rather than half driven.
- if (
- !child.navigationRows ||
- !child.registerFocusTargets ||
- !child.resolvePreferredTargetId ||
- !child.setScopeId
+ if (!child.registerFocusTargets) return undefined
+ // A stack with no scope drives nothing; its children register and
+ // navigate themselves.
+ if (this.scopeId_ === undefined) return undefined
+ // A stack that owns a scope is the only thing that will register
+ // this child, so half of the composable surface is not enough: the
+ // child would register nothing and its controls would be
+ // unreachable. Fail here rather than at the missing member.
+ control.assert(
+ !!child.navigationRows &&
+ !!child.resolvePreferredTargetId &&
+ !!child.setScopeId,
+ "a focusable child of a scoped stack must implement UiComposableFocusView",
)
- return undefined
return >child
}