From 8a33c23abb6a62d901970be0ebbe32bfa98bc107 Mon Sep 17 00:00:00 2001 From: Eric Anderson Date: Sun, 2 Aug 2026 21:58:35 -0700 Subject: [PATCH 1/2] feat: let parent view own focus scope for its children --- focus.ts | 11 +++++++++-- screen.ts | 16 +++++++++++++--- view.ts | 42 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/focus.ts b/focus.ts index 80f7a6c..fcbe1fb 100644 --- a/focus.ts +++ b/focus.ts @@ -582,13 +582,20 @@ namespace ui { return !!target && target.scopeId == scopeId && !target.hidden } + /** + * Drops a scope's retained target when that target is no longer + * eligible. When the scope is active, focus is re-resolved to the + * scope's preferred target so that hiding the focused control does not + * strand focus on something no movement can start from. + */ private clearRetainedActiveTarget( scopeId: UiFocusScopeId, targetId: UiFocusId, ): void { const scope = this.findScope(scopeId) - if (scope && scope.activeTargetId == targetId) - scope.activeTargetId = undefined + if (!scope || scope.activeTargetId != targetId) return + scope.activeTargetId = undefined + if (this.activeScopeId_ == scopeId) this.activateScope(scope) } private activateScope(scope: UiFocusScopeRecord): UiFocusSetResult { diff --git a/screen.ts b/screen.ts index 14b2302..372e6cf 100644 --- a/screen.ts +++ b/screen.ts @@ -424,13 +424,23 @@ namespace ui { return undefined } + /** + * Focuses the first root that can take focus. Roots whose targets are + * all hidden, and container views that own no scope, are skipped rather + * than swallowing the screen's initial focus. + */ private focusFirstRoot(): UiFocusSetResult | undefined { + let firstResult: UiFocusSetResult | undefined = undefined for (let i = 0; i < this.roots_.length; i++) { const view = this.roots_[i].view - if ((view).registerFocusTargets) - return (view).focusDefault(this.focus_) + if (!(view).registerFocusTargets) continue + const result = ( + (view).focusDefault(this.focus_) + ) + if (result && result.kind == "focused") return result + if (!firstResult) firstResult = result } - return undefined + return firstResult } private defaultHandled(result: TResult): boolean | undefined { diff --git a/view.ts b/view.ts index 6f8b001..ad928d2 100644 --- a/view.ts +++ b/view.ts @@ -24,8 +24,14 @@ namespace ui { export interface UiFocusableView extends UiView { /** * Registers focus targets after layout has arranged this view. + * + * `scopeOptions` lets a parent view register this view's targets under a + * scope the parent owns. */ - registerFocusTargets(focus: UiFocusState): void + registerFocusTargets( + focus: UiFocusState, + scopeOptions?: UiFocusScopeOptions, + ): void /** * Registers directional navigation after layout has arranged this view. @@ -38,6 +44,40 @@ namespace ui { focusDefault(focus: UiFocusState): UiFocusSetResult } + /** + * Focusable view that a parent view can compose into a scope the parent + * owns, so several views navigate as one. + * + * The parent assigns its scope with `setScopeId`, collects each child's + * targets with `navigationRows`, and registers one navigation for the whole + * group. Children keep rendering, measuring, and arranging themselves. + */ + export interface UiComposableFocusView + extends UiFocusableView { + /** + * Focus scope this view registers its targets under. + */ + scopeId: UiFocusScopeId + + /** + * Adopts an owner scope. Target ids are rebuilt from the new scope, so + * this runs before any focus registration. + */ + setScopeId(scopeId: UiFocusScopeId): void + + /** + * Rows of navigation targets in movement order, using this view's + * current arranged rectangles and visibility. + */ + navigationRows(): UiFocusNavigationTarget[][] + + /** + * Target this view would focus by default, or `undefined` when it has + * no focusable target. + */ + resolvePreferredTargetId(): UiFocusId | undefined + } + /** * Screen placement for a root view. */ From 79fce089f2fa203ba91e8834ec6064f627a973e8 Mon Sep 17 00:00:00 2001 From: Eric Anderson Date: Sun, 2 Aug 2026 21:58:54 -0700 Subject: [PATCH 2/2] feat: render focus after all roots --- screen.ts | 17 ++++++++++++++++- view.ts | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/screen.ts b/screen.ts index 372e6cf..29c0393 100644 --- a/screen.ts +++ b/screen.ts @@ -269,9 +269,24 @@ namespace ui { return undefined } + /** + * Renders the root views, then their focus treatments, then the active + * modal. Focus treatments extend past their control, so drawing them in + * a pass of their own keeps a focus label from being covered by a root + * rendered after it. Views that do not separate the two render whole in + * the first pass. + */ private renderViews(surface: DrawSurface): void { for (let i = 0; i < this.roots_.length; i++) { - this.roots_[i].view.render(surface, this.assets, this.focus_) + const view = this.roots_[i].view + if (view.renderControls) + view.renderControls(surface, this.assets, this.focus_) + else view.render(surface, this.assets, this.focus_) + } + for (let i = 0; i < this.roots_.length; i++) { + const view = this.roots_[i].view + if (view.renderFocus) + view.renderFocus(surface, this.assets, this.focus_) } if (this.activeModal_) this.activeModal_.render(surface, this.assets, this.focus_) diff --git a/view.ts b/view.ts index ad928d2..32eef7e 100644 --- a/view.ts +++ b/view.ts @@ -71,6 +71,28 @@ namespace ui { */ navigationRows(): UiFocusNavigationTarget[][] + /** + * Renders this view without its focus treatment. + * + * A focus treatment extends past the control it belongs to, so a parent + * draws every child's controls before any child's focus treatment. + * Otherwise a focus label is covered by whatever is rendered next. + */ + renderControls( + surface: DrawSurface, + assets: UiAssetResolver, + focus?: UiFocusState, + ): void + + /** + * Renders only this view's focus treatment. + */ + renderFocus( + surface: DrawSurface, + assets: UiAssetResolver, + focus?: UiFocusState, + ): void + /** * Target this view would focus by default, or `undefined` when it has * no focusable target.