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..6501b6a 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. */ @@ -736,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() @@ -762,7 +829,7 @@ namespace ui { this.finalRect, this.controlView_, undefined, - labelBounds, + _uiControls.resolveLabelBounds(surface, this.labelBounds_), true, assets, ) diff --git a/grid.ts b/grid.ts index 62efae6..a91e455 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,11 +48,11 @@ 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 - private scopeId_: UiFocusScopeId + private scopeId_: UiFocusScopeId | undefined private controls_: UiControl[] private defaultControlId_: string private scrollOwnerId_: UiFocusScrollOwnerId @@ -93,10 +95,19 @@ namespace ui { /** * Focus scope id used by this grid. */ - public get scopeId(): UiFocusScopeId { + public get scopeId(): UiFocusScopeId | undefined { 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(), @@ -216,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_) } @@ -226,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_, @@ -403,15 +425,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/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": {} } diff --git a/row.ts b/row.ts index 4e4f07b..d1d858b 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,11 +73,11 @@ 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 - private scopeId_: UiFocusScopeId + private scopeId_: UiFocusScopeId | undefined private controls_: UiRowControl[] private defaultControlId_: string private scrollOwnerId_: UiFocusScrollOwnerId @@ -115,10 +117,19 @@ namespace ui { /** * Focus scope id used by this row. */ - public get scopeId(): UiFocusScopeId { + public get scopeId(): UiFocusScopeId | undefined { 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(), @@ -270,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_) } @@ -280,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/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..48461eb 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,10 @@ namespace ui { orientation: UiStackOrientation /** - * Child views in arrangement order. + * Child records in arrangement order. Omitted for a stack whose + * children are set later with `setChildren`. */ - children: UiView[] + children?: UiStackChild[] /** * Space between adjacent children. @@ -24,51 +59,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 | undefined + 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 | undefined { + return this.scopeId_ } /** - * Current child views. + * Adopts an owner scope and passes it on to the children, so a stack + * nests inside another stack's scope. */ - public get children(): UiView[] { + public setScopeId(scopeId: UiFocusScopeId): void { + this.scopeId_ = scopeId + for (let i = 0; i < this.children_.length; i++) + this.adoptScope(this.children_[i]) + } + + /** + * Current child records in arrangement order. + */ + 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 +181,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 +214,33 @@ 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 + // 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) 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 +248,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() } @@ -180,22 +293,191 @@ namespace ui { } /** - * Renders the child views. + * Registers the scope this stack owns and its children's targets under + * 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) { + 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(), + } + 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. A stack + * that owns no scope lets each child register its own navigation. + */ + public registerNavigation(controller: UiFocusInputController): void { + 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. 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 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" } + } + + /** + * 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, 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].render(surface, assets, focus) + const view = this.children_[i].view + if (view.renderFocus) view.renderFocus(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++) { + // 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 } @@ -204,10 +486,104 @@ 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 | undefined { + const child = this.children_[index].view + 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 >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, + ) + } } } 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!")