Files
2026-10-14 04:20:24 +02:00

391 lines
15 KiB
JavaScript

// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution term: see LICENSE-ATTRIBUTION.
// Focus state without React: focusable nodes, nested scopes, D-pad moves, press, back and recovery.
// Components keep one node or scope each and re-render only when their own node changes.
import {reachable, screenRect, scrolledBy} from './frames.js';
import {findClosest, findNearest, findWrap} from './geometry.js';
/**
* @typedef {import('./geometry.js').Rect} Rect
* @typedef {import('./geometry.js').Direction} Direction
* @typedef {{parent: Frame | null, x: number, y: number, viewport: Rect | null,
* reveal: (rect: Rect, anchor: Rect | null) => void}} Frame
* A scrolling ancestor; `x`/`y` is how far its content is scrolled, `viewport` its pre-scroll rectangle.
* @typedef {{parent: Anchor | null, frame: Frame | null, rect: Rect | null}} Anchor
* An element with `scrollAnchor`, whose start its ScrollView aligns when revealing a descendant.
* @typedef {{focusKey?: string, onPress?: () => void, onFocus?: () => void, onBlur?: () => void,
* autoFocus?: boolean, disabled?: boolean, nextFocusUp?: string, nextFocusDown?: string,
* nextFocusLeft?: string, nextFocusRight?: string}} NodeProps
* @typedef {{focusKey?: string, autoFocus?: boolean, trap?: boolean, wrap?: boolean, row?: boolean,
* restoreFocus?: boolean, inert?: boolean, onBack?: () => boolean | void,
* onAction?: (action: Action) => boolean | void}} ScopeProps
* `inert` takes the scope's subtree out of navigation (a hidden layer that stays mounted).
* @typedef {import('../input.js').ButtonAction} Action a button beyond D-pad, Cross and Circle
* @typedef {{kind: 'scope', parent: Scope | null, props: ScopeProps, remembered: Node | null,
* returnTo: Node | null, dead: boolean}} Scope
* @typedef {{kind: 'node', scope: Scope, frame: Frame | null, anchor: Anchor | null, props: NodeProps,
* rect: Rect | null, order: number, pressed: boolean, dead: boolean, listeners: Set<() => void>,
* pressTimer: unknown}} Node
*/
const NEXT = {up: 'nextFocusUp', down: 'nextFocusDown', left: 'nextFocusLeft', right: 'nextFocusRight'};
/** @param {Scope | null} scope @param {Scope} ancestor */
function within(scope, ancestor) {
for (; scope; scope = scope.parent) if (scope === ancestor) return true;
return false;
}
/** Whether no scope from `scope` up is inert. @param {Scope | null} scope */
function navigable(scope) {
for (; scope; scope = scope.parent) if (scope.props.inert) return false;
return true;
}
export class FocusManager {
/**
* @param {{schedule?: (fn: () => void) => void, setTimer?: typeof setTimeout,
* clearTimer?: typeof clearTimeout, pressMs?: number}} [options]
* `schedule` runs focus recovery after the commit that unmounted the focused node.
*/
constructor({schedule = fn => { Promise.resolve().then(fn); }, setTimer = setTimeout,
clearTimer = clearTimeout, pressMs = 120} = {}) {
Object.assign(this, {schedule, setTimer, clearTimer, pressMs});
this.root = this.createScope(null, {});
/** @type {Set<Node>} */
this.nodes = new Set();
/** @type {Map<string, Node | Scope>} */
this.keys = new Map();
/** @type {Node | null} */
this.focused = null;
/** @type {Set<() => void>} */
this.listeners = new Set();
this.order = 0;
/** @type {Set<object>} owners about to focus something that is still mounting */
this.claims = new Set();
/** What recovery would have started from while a claim held it off. */
this.lostWhileClaimed = null;
}
/**
* Holds focus recovery and D-pad moves from nowhere off while `owner` (a VirtualList jumping to an
* item it is still mounting) is about to focus something: otherwise the focused row unmounting
* under the jump sends focus to whatever survives nearest, often another page's tab. The returned
* release recovers as usual if nothing took focus meanwhile.
* @param {object} owner @returns {() => void} release
*/
claimFocus(owner) {
this.claims.add(owner);
return () => {
if (!this.claims.delete(owner) || this.claims.size || this.focused) return;
const lost = this.lostWhileClaimed;
this.lostWhileClaimed = null;
if (lost) this.recover(lost);
};
}
/** @param {Scope | null} parent @param {ScopeProps} props @returns {Scope} */
createScope(parent, props) {
return {kind: 'scope', parent, props, remembered: null, returnTo: null, dead: false};
}
/**
* Called while rendering, so `order` follows document order for elements mounted together.
* @param {Scope} scope @param {Frame | null} frame @param {NodeProps} props
* @param {Anchor | null} [anchor] the nearest `scrollAnchor` ancestor (or the element itself) @returns {Node}
*/
createNode(scope, frame, props, anchor = null) {
return {kind: 'node', scope, frame, anchor, props, rect: null, order: this.order++, pressed: false, dead: false,
listeners: new Set(), pressTimer: null};
}
/** Replaces an entry's props, keeping its `focusKey` registered. @param {Node | Scope} entry @param {object} props */
setProps(entry, props) {
const previous = entry.props.focusKey;
const becameInert = props.inert && !entry.props.inert;
entry.props = props;
// Deferred like recovery, so a screen mounted in the same commit can take focus first.
if (becameInert) this.schedule(() => this.evict());
if (entry.dead || previous === props.focusKey) return;
if (previous != null && this.keys.get(previous) === entry) this.keys.delete(previous);
if (props.focusKey != null) this.keys.set(props.focusKey, entry);
}
/** @param {Node} node */
register(node) {
node.dead = false;
this.nodes.add(node);
if (node.props.focusKey != null) this.keys.set(node.props.focusKey, node);
if (node.props.autoFocus && navigable(node.scope) && !(this.focused && within(this.focused.scope, node.scope))) {
this.setFocus(node);
}
}
/** @param {Node} node */
unregister(node) {
node.dead = true;
this.nodes.delete(node);
if (this.keys.get(node.props.focusKey) === node) this.keys.delete(node.props.focusKey);
this.clearTimer(node.pressTimer);
if (this.focused !== node) return;
this.focused = null;
const lost = {scope: node.scope, rect: screenRect(node)};
this.schedule(() => this.recover(lost));
}
/** @param {Scope} scope */
mountScope(scope) {
scope.dead = false;
if (scope.props.focusKey != null) this.keys.set(scope.props.focusKey, scope);
if (scope.props.autoFocus && navigable(scope) && !(this.focused && within(this.focused.scope, scope))) {
const target = this.firstOf(scope);
if (target) this.setFocus(target);
}
}
/** @param {Scope} scope */
unmountScope(scope) {
scope.dead = true;
if (this.keys.get(scope.props.focusKey) === scope) this.keys.delete(scope.props.focusKey);
}
/** Laid-out screen rectangle, before transforms and scrolling. @param {Node} node @param {Rect} rect */
setRect(node, rect) {
node.rect = rect;
}
/** @param {() => void} listener @returns {() => void} */
subscribe(listener) {
this.listeners.add(listener);
return () => { this.listeners.delete(listener); };
}
/**
* Focuses a node, a scope (its remembered or first element), or the entry registered under a key.
* @param {Node | Scope | string} target
* @returns {boolean} whether something took focus
*/
focus(target) {
const entry = typeof target === 'string' ? this.keys.get(target) : target;
const node = entry?.kind === 'scope' ? this.firstOf(entry) : entry;
if (!node || node.dead || !navigable(node.scope)) return false;
this.setFocus(node);
return true;
}
blur() {
this.setFocus(null);
}
/** @param {Direction} direction @returns {boolean} whether focus moved */
move(direction) {
const current = this.focused;
if (!current) return this.claims.size ? false : this.focus(this.root);
const override = current.props[NEXT[direction]];
if (override != null) return this.focus(override);
const from = screenRect(current);
if (!from) return false;
const frames = new Set();
for (let frame = current.frame; frame; frame = frame.parent) frames.add(frame);
for (let scope = current.scope; scope; scope = scope.parent) {
const candidates = this.candidates(scope)
.filter(candidate => candidate.node !== current && reachable(candidate.node, frames));
const winner = findNearest(from, candidates, direction)
?? (scope.props.wrap ? findWrap(from, candidates, direction) : null);
if (winner) {
this.setFocus(this.entryPoint(winner.node, current));
return true;
}
if (scope.props.trap) return false;
// A row ends at its ends: Left and Right never land in the rows around it.
if (scope.props.row && (direction === 'left' || direction === 'right')) return false;
}
return false;
}
/**
* Cross: press feedback for `pressMs`, then the focused element's onPress.
* @returns {boolean | null} whether an enabled element with `onPress` took it; null without focus
*/
press() {
const node = this.focused;
if (!node) return null;
this.clearTimer(node.pressTimer);
node.pressed = true;
node.pressTimer = this.setTimer(() => {
node.pressed = false;
this.notify(node);
}, this.pressMs);
this.notify(node);
if (node.props.disabled || !node.props.onPress) return false;
node.props.onPress();
return true;
}
/** Circle: each enclosing scope's `onBack`, innermost first, until one consumes it. @returns {boolean} */
back() {
for (let scope = this.focused?.scope ?? this.root; scope; scope = scope.parent) {
if (scope.props.onBack?.() === true) return true;
}
return false;
}
/**
* Shoulders, triggers, Triangle, Square and Options: each enclosing scope's `onAction`, innermost first,
* until one consumes it. @param {Action} action @returns {boolean}
*/
action(action) {
for (let scope = this.focused?.scope ?? this.root; scope; scope = scope.parent) {
if (scope.props.onAction?.(action) === true) return true;
}
return false;
}
/** @param {Scope} scope @returns {Array<{node: Node, rect: Rect, order: number}>} */
candidates(scope) {
const result = [];
for (const node of this.nodes) {
if (!within(node.scope, scope) || !navigable(node.scope)) continue;
const rect = screenRect(node);
if (rect && (rect.width > 0 || rect.height > 0)) result.push({node, rect, order: node.order});
}
return result;
}
/** A row's remembered element, else its `selected` one, else its first. @param {Scope} scope @returns {Node | null} */
rowEntry(scope) {
const remembered = scope.remembered;
if (remembered && !remembered.dead && navigable(remembered.scope) && scope.props.restoreFocus !== false) {
return remembered;
}
for (const node of this.nodes) {
if (node.props.selected && within(node.scope, scope) && navigable(node.scope)) return node;
}
return this.firstOf(scope);
}
/** @param {Scope} scope @returns {Node | null} */
firstOf(scope) {
const remembered = scope.remembered;
if (remembered && !remembered.dead && navigable(remembered.scope) && scope.props.restoreFocus !== false) {
return remembered;
}
let first = null;
for (const node of this.nodes) {
if (within(node.scope, scope) && navigable(node.scope) && (!first || node.order < first.order)) first = node;
}
return first;
}
/**
* The node to focus when a move lands on `winner`: entering a scope restores its remembered element;
* a row entered for the first time starts at its `selected` element, or else its first, wherever the
* move came from.
*/
entryPoint(winner, current) {
let entered = null;
for (let scope = winner.scope; scope && !within(current.scope, scope); scope = scope.parent) entered = scope;
if (entered?.props.row) return this.rowEntry(entered) ?? winner;
const remembered = entered?.remembered;
return remembered && !remembered.dead && navigable(remembered.scope) && entered.props.restoreFocus !== false
? remembered : winner;
}
/**
* Where the focused element is on screen, in render pixels: its layout rectangle moved by the
* ScrollViews around it (to where they are scrolling), before transforms. Null with nothing focused.
* @returns {Rect | null}
*/
focusedRect() {
const rect = this.focused && screenRect(this.focused);
return rect ? {...rect} : null;
}
/**
* @param {Node | null} next
* @param {boolean} [returning] focus goes back to where a closed layer was opened from: the scopes it
* lands in keep where they return to, instead of returning into the closed layer
*/
setFocus(next, returning = false) {
const previous = this.focused;
if (previous === next) return;
this.focused = next;
for (let scope = next?.scope; scope; scope = scope.parent) {
if (!returning && (!previous || !within(previous.scope, scope))) scope.returnTo = previous;
scope.remembered = next;
}
if (previous) {
this.notify(previous);
previous.props.onBlur?.();
}
if (next) {
this.notify(next);
next.props.onFocus?.();
this.reveal(next);
}
for (const listener of this.listeners) listener();
}
/**
* Scrolls every scrolling ancestor, innermost first, until `node` is in view; each one aligns the
* nearest `scrollAnchor` ancestor that it contains directly, if any. @param {Node} node
*/
reveal(node) {
let rect = node.rect;
if (!rect) return;
for (let frame = node.frame; frame; frame = frame.parent) {
let anchor = node.anchor;
while (anchor && anchor.frame !== frame) anchor = anchor.parent;
frame.reveal(rect, anchor?.rect ?? null);
rect = scrolledBy(rect, frame);
}
}
/** After the focused node unmounted: a dead scope's previous focus, else the nearest survivor. */
recover(lost) {
if (this.focused) return;
if (this.claims.size) {
this.lostWhileClaimed = lost;
return;
}
this.lostWhileClaimed = null;
const {node, back} = this.fallback(lost);
if (node) this.setFocus(node, back);
else for (const listener of this.listeners) listener();
}
/** Moves focus out of a subtree that became inert, the way recovery leaves an unmounted one. */
evict() {
const node = this.focused;
if (!node || navigable(node.scope)) return;
const target = this.fallback({scope: node.scope, rect: screenRect(node)});
this.setFocus(target.node, target.back);
}
/**
* Where focus goes when it must leave `scope`: an unusable (dead or inert) scope's previous focus,
* else the navigable element nearest `rect`, else the scope's remembered or first one, outward.
* `back` tells the first from the rest.
* @param {{scope: Scope | null, rect: Rect | null}} from
* @returns {{node: Node | null, back: boolean}}
*/
fallback({scope, rect}) {
for (; scope; scope = scope.parent) {
if (scope.dead || !navigable(scope)) {
const back = scope.returnTo;
if (back && !back.dead && navigable(back.scope)) return {node: back, back: true};
continue;
}
const node = (rect && findClosest(rect, this.candidates(scope))?.node) ?? this.firstOf(scope);
if (node) return {node, back: false};
}
return {node: null, back: false};
}
/** @param {Node} node */
notify(node) {
for (const listener of node.listeners) listener();
}
}