Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 102 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -309,13 +311,113 @@ class DataGraphScreen extends ui.UiScreen {
<img src="./assets/data-vis.png" width="40%">
</p>

## 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<string> {
const controls: ui.UiControl<string>[] = []
for (let i = 0; i < texts.length; i++)
controls.push(ui.button<string>(texts[i], texts[i], () => {}))

return new ui.UiRow<string>({
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,
and confirmation dialogs.
- 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**
Expand Down
93 changes: 80 additions & 13 deletions button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,9 @@ namespace ui {
* Screen-managed button with retained layout, focus, rendering, and activation.
*/
export class UiButton<T = string>
implements UiFocusableView<UiButtonResult<T>>, UiFocusNavigationProvider
implements
UiComposableFocusView<UiButtonResult<T>>,
UiFocusNavigationProvider
{
public readonly layoutSpec: UiLayoutSpec
public readonly finalRect: Rect
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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()
Expand All @@ -762,7 +829,7 @@ namespace ui {
this.finalRect,
this.controlView_,
undefined,
labelBounds,
_uiControls.resolveLabelBounds(surface, this.labelBounds_),
true,
assets,
)
Expand Down
40 changes: 32 additions & 8 deletions grid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,11 @@ namespace ui {
export interface UiGridOptions<T>
extends UiControlCollectionOptions<T>, 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.
Expand Down Expand Up @@ -46,11 +48,11 @@ namespace ui {
/**
* Renders and navigates a rectangular or ragged control grid.
*/
export class UiGrid<T> implements UiFocusableView<UiGridResult<T>> {
export class UiGrid<T> implements UiComposableFocusView<UiGridResult<T>> {
public readonly layoutSpec: UiLayoutSpec
public readonly finalRect: Rect
public layoutDirty: boolean
Comment thread
humanapp marked this conversation as resolved.
private scopeId_: UiFocusScopeId
private scopeId_: UiFocusScopeId | undefined
private controls_: UiControl<T>[]
private defaultControlId_: string
private scrollOwnerId_: UiFocusScrollOwnerId
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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_,
Expand All @@ -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(),
Expand All @@ -216,16 +232,22 @@ 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_)
}

/**
* 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_,
Expand Down Expand Up @@ -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,
})
}
Expand Down
2 changes: 1 addition & 1 deletion pxt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {}
}
Loading
Loading