diff --git a/packages/core/src/renderables/grid/components.d.ts b/packages/core/src/renderables/grid/components.d.ts new file mode 100644 index 000000000..b4e824fc1 --- /dev/null +++ b/packages/core/src/renderables/grid/components.d.ts @@ -0,0 +1,9 @@ +import GridRenderable from "./grid"; +import GridNodeRenderable from "./grid_node"; + +declare module "@opentui/solid" { + interface OpenTUIComponents { + grid: typeof GridRenderable; + grid_node: typeof GridNodeRenderable; + } +} diff --git a/packages/core/src/renderables/grid/constants.ts b/packages/core/src/renderables/grid/constants.ts new file mode 100644 index 000000000..12b2715fb --- /dev/null +++ b/packages/core/src/renderables/grid/constants.ts @@ -0,0 +1,90 @@ +import type { GridKeys } from "./types" + +export const innerGridKeys = (props: { + orientation: "vertical" | "horizontal" + id: string + coords: [number, number] +}): GridKeys => ({ + movement: { + overflowBehavior: "wrap-around", + ...(props.orientation === "vertical" + ? { + up: { + name: "up", + overflowBehavior: props.id === "x, y" ? "stop" : undefined, + }, + down: { + name: "down", + overflowBehavior: props.id === "-x, -y" ? "stop" : undefined, + }, + } + : { + left: { + name: "left", + overflowBehavior: props.id === "x, -y" ? "stop" : undefined, + }, + right: { + name: "right", + overflowBehavior: props.id === "-x, y" ? "stop" : undefined, + }, + }), + }, +}) + +export const outerGridElements: { + id: string + orientation: "vertical" | "horizontal" + coords: [number, number] +}[] = [ + { + id: "-x, -y", + coords: [0, 0], + orientation: "vertical", + }, + { + id: "x, -y", + coords: [0, 1], + orientation: "horizontal", + }, + { + id: "-x, y", + coords: [1, 0], + orientation: "horizontal", + }, + { + id: "x, y", + coords: [1, 1], + orientation: "vertical", + }, +] + +export const outerGridKeys = { + actions: { + focus: { + name: "return", + }, + unfocus: { + name: "escape", + }, + }, + + movement: { + overflowBehavior: "wrap-around", + left: { + name: "left", + conditions: ["ctrl"], + }, + right: { + name: "right", + conditions: ["ctrl"], + }, + up: { + name: "up", + conditions: ["ctrl"], + }, + down: { + name: "down", + conditions: ["ctrl"], + }, + }, +} diff --git a/packages/core/src/renderables/grid/grid.ts b/packages/core/src/renderables/grid/grid.ts new file mode 100644 index 000000000..fc256b92f --- /dev/null +++ b/packages/core/src/renderables/grid/grid.ts @@ -0,0 +1,225 @@ +import GridNodeRenderable from "./grid_node" +import type { KeyEvent, RenderContext } from "@opentui/core" +import type { GridKeys, GridOptions, MovementDirection } from "./types" +import { eventMatchesConditions } from "./utils" + +export default class GridRenderable extends GridNodeRenderable { + /** 2D matrix of grid nodes indexed by coordinates. */ + private matrix: GridNodeRenderable[][] = [[]] + + // === Registry === + /** Lookup table for nodes by id. */ + private gridNodesById: { [id: string]: GridNodeRenderable } = {} + /** Currently focused node inside this grid. Does not get modified when the node gets blurred*/ + private _currentFocusLocal?: GridNodeRenderable + + // === Keys === + /** Stored keydown handler so we can preserve wrapper behavior. */ + private externalGridKeydown?: (event: KeyEvent) => void + /** Keymap for movement and actions handled by the grid. */ + public keys: GridKeys = { movement: {} } + + constructor(ctx: RenderContext, options: GridOptions) { + super(ctx, options) + + // NOTE: Initialize wrapper so keydown bubbling is consistent. + this.onKeyDown = undefined + } + + /** Return the node at a given coordinate, if any. */ + public childAt([x, y]: [number, number]) { + return this.matrix[x]?.[y] + } + + // === Focus: local === + public set currentFocusLocal(node: GridNodeRenderable | undefined) { + const prev = this._currentFocusLocal + if (prev === node) return + + this._currentFocusLocal = node + + // Ensure blur/focus are paired when focus changes. + prev?.onBlurLocal?.({ target: prev }) + node?.onFocusLocal?.({ target: node }) + } + public get currentFocusLocal() { + return this._currentFocusLocal + } + + /** Update the id lookup when id gets changed. */ + // NOTE: This is mostly for Solid behavior but it barely does anything on that regard. + // So it should be modified to better handle Solid on-update-id-shifting + public updateChildId(previousId: string, nextId: string, node: GridNodeRenderable) { + if (previousId === nextId) return + + if (this.gridNodesById[previousId] === node) delete this.gridNodesById[previousId] + + this.gridNodesById[nextId] = node + } + + // === Key handling === + /** Move local focus to a neighbor based on direction, honoring overflow settings. */ + private focusLocalNeighbor(direction: MovementDirection, [dx, dy]: [number, number], ev: KeyEvent) { + if (!this.currentFocusLocal) return + if (!eventMatchesConditions(ev, this.keys.movement[direction]?.conditions)) return + + const movement = this.keys.movement + const [x, y] = this.currentFocusLocal.coords + const overflowBehavior = movement[direction]?.overflowBehavior ?? movement.overflowBehavior + + let row = this.matrix[x + dx] + let neighbor = row?.[y + dy] + + if (!neighbor) { + switch (overflowBehavior) { + case "stop": + // Stop propagation so parent grids do not interpret the key. + ev.stopPropagation() + return + case "wrap-around": + if (!row) { + row = this.matrix[dx < 0 ? this.matrix.length - 1 : 0] + neighbor = row?.[y + dy] + } else { + neighbor = row?.[dy < 0 ? row.length - 1 : 0] + } + break + default: + return + } + } + + ev.stopPropagation() + + if (this.keys.actions) { + neighbor?.focusLocal() + this.focus() + } else { + neighbor?.focus() + } + } + + /** Handle movement keys and action bindings. */ + private handleGridKeys(ev: KeyEvent) { + const movement = this.keys.movement + + switch (ev.name) { + case movement.left?.name: + return this.focusLocalNeighbor("left", [0, -1], ev) + case movement.right?.name: + return this.focusLocalNeighbor("right", [0, 1], ev) + case movement.up?.name: + return this.focusLocalNeighbor("up", [-1, 0], ev) + case movement.down?.name: + return this.focusLocalNeighbor("down", [1, 0], ev) + } + + const actions = this.keys.actions + if (!actions || !this.currentFocusLocal) return + + switch (ev.name) { + case actions.focus.name: + if (eventMatchesConditions(ev, actions.focus.conditions)) + // Promote focused local node to full focus. + this.currentFocusLocal.focus() + break + case actions.unfocus.name: + if (eventMatchesConditions(ev, actions.focus.conditions)) + // Return focus to the grid container. + this.focus() + break + } + + ev.stopPropagation() + } + + /** Wrap keydown so grid navigation runs before external handlers. */ + public override set onKeyDown(handler: ((key: KeyEvent) => void) | undefined) { + const wrapper = (event: KeyEvent) => { + this.handleGridKeys(event) + handler?.(event) + } + this.externalGridKeydown = wrapper + super.onKeyDown = wrapper + } + + public override get onKeyDown() { + return this.externalGridKeydown + } + + // === Node lifecycle === + /** Insert a node into the matrix and initialize local focus. */ + override add(node: unknown, index?: number | undefined): number { + if (node instanceof GridNodeRenderable) { + const [x, y] = node.coords + + if (this.matrix[x] === undefined) { + this.matrix[x] = [] + } + + const existing = this.matrix[x][y] + if (existing && existing !== node) { + this.remove(existing.id) + } + + this.matrix[x][y] = node + + // TODO: If a component momentarily goes to 0 children due to an update, this might not work as expected + // Try to create a scenario where that happens and see if it causes an issue + if (x === 0 && y === 0) { + this.currentFocusLocal = node + } + + this.gridNodesById[node.id] = node + } + + return super.add(node, index) + } + + /** Remove node and patch matrix/focus state. */ + override remove(id: string) { + const node = this.gridNodesById[id] + + if (node instanceof GridNodeRenderable) { + const [x, y] = node.coords + const wasFocused = node.focused + + if (node.focusedLocal) { + // Try to preserve local focus by selecting a neighbor. + const neighbor = + this.childAt([x - 1, y]) ?? this.childAt([x + 1, y]) ?? this.childAt([x, y - 1]) ?? this.childAt([x, y + 1]) + + if (neighbor) { + if (wasFocused) { + neighbor.focus() + } else { + neighbor.focusLocal() + } + } + } + + const matrix = this.matrix + + matrix[x]?.splice(y, 1) + + if (matrix[x]?.length === 0) { + matrix.splice(x, 1) + + if (matrix.length === 0) { + // NOTE: Order here is important because user could have + // `onFocus={(ev) => ev.target.currentFocusLocal?.focus()}` which would cause them to + // focus the component we are about to delete, losing the general focus. + this.currentFocusLocal = undefined + + if (wasFocused) { + this.focus() + } + } + } + + delete this.gridNodesById[id] + } + + super.remove(id) + } +} diff --git a/packages/core/src/renderables/grid/grid_node.ts b/packages/core/src/renderables/grid/grid_node.ts new file mode 100644 index 000000000..83ec462a2 --- /dev/null +++ b/packages/core/src/renderables/grid/grid_node.ts @@ -0,0 +1,198 @@ +import { BoxRenderable, KeyEvent, Renderable, RenderableEvents } from "@opentui/core" +import { type RenderContext } from "@opentui/core" +import GridRenderable from "./grid" +import { bubbleKeyDown, walkGridNodeAncestors } from "./utils" +import { prevFocusedRenderable, type AddPrevFocusedRenderable } from "./symbols" +import type { + EventHandler, + BlurEvent, + BlurLocalEvent, + BlurWithinEvent, + FocusEvent, + FocusLocalEvent, + FocusWithinEvent, + GridNodeOptions, +} from "./types" + +export default class GridNodeRenderable extends BoxRenderable { + /** Grid coordinates in the parent matrix. Managed externally. */ + public coords: [number, number] = [0, 0] + + // === Focus state === + /** Tracks focus-within for the grid subtree. */ + protected _focusedWithin: boolean = false + + /** Focus handlers for this node. */ + public _onFocus?: EventHandler> + public _onFocusWithin?: EventHandler> + public _onFocusLocal?: EventHandler> + + /** Blur handlers for this node. */ + public _onBlur?: EventHandler> + public _onBlurWithin?: EventHandler> + public _onBlurLocal?: EventHandler> + + // === Key handling === + /** Stored external handler so we can wrap bubbling logic safely. */ + private externalKeyDown?: (event: KeyEvent) => void + + constructor(ctx: RenderContext, options: GridNodeOptions) { + super(ctx, options) + + this._focusable = true + this.on(RenderableEvents.FOCUSED, this.handleFocusEvent) + this.on(RenderableEvents.BLURRED, this.handleBlurEvent) + } + + override focus() { + super.focus() + } + + // === Identity === + /** Sync id changes back into the parent grid lookup table. */ + public override set id(id: string) { + const previousId = super.id + super.id = id + + if (this.parent instanceof GridRenderable && previousId !== id) { + this.parent.updateChildId(previousId, id, this) + } + } + + public override get id() { + return super.id + } + + // === Focus: primary === + set onFocus(handler: EventHandler> | undefined) { + this._onFocus = handler + } + get onFocus(): EventHandler> | undefined { + return this._onFocus + } + + set onBlur(handler: EventHandler> | undefined) { + this._onBlur = handler + } + get onBlur() { + return this._onBlur + } + + /** Primary focus handler. Updates focus-within and local focus state. */ + private handleFocusEvent() { + let current: Renderable | null = this + + while (current) { + if (!(current instanceof GridNodeRenderable)) { + current = current.parent + continue + } + + if (current.focusedWithin) { + // Blur any previous focus-within chain before re-focusing. + walkGridNodeAncestors((this._ctx as AddPrevFocusedRenderable)[prevFocusedRenderable], current, (node) => + node.blurWithin(), + ) + break + } + + // Bubble focus-within and focus-local up to the grid root. + current.focusWithin() + current.focusLocal() + current = current.parent + } + + // NOTE: This must be called after the while loop, otherwise someone could do + // `onFocus={() => somethingElse.focus()}` and break the whole chain. + this._onFocus?.({ target: this }) + this.focusLocal() + } + + /** Stores the last focused node so focus-within can be blurred next time. */ + private handleBlurEvent() { + this._onBlur?.({ target: this }) + ;(this._ctx as AddPrevFocusedRenderable)[prevFocusedRenderable] = this + } + + // === Focus: within === + set onFocusWithin(handler: EventHandler> | undefined) { + this._onFocusWithin = handler + } + get onFocusWithin(): EventHandler> | undefined { + return this._onFocusWithin + } + + set onBlurWithin(handler: EventHandler> | undefined) { + this._onBlurWithin = handler + } + get onBlurWithin() { + return this._onBlurWithin + } + + protected get focusedWithin() { + return this._focusedWithin + } + + protected focusWithin() { + this._focusedWithin = true + this._onFocusWithin?.({ + target: this, + }) + } + + protected blurWithin() { + this._focusedWithin = false + this._onBlurWithin?.({ target: this }) + } + + // === Focus: local === + set onFocusLocal(handler: EventHandler> | undefined) { + this._onFocusLocal = handler + } + get onFocusLocal(): EventHandler> | undefined { + return this._onFocusLocal + } + + set onBlurLocal(handler: EventHandler> | undefined) { + this._onBlurLocal = (ev) => { + // Blur normal focus first to keep parent state consistent. + this.blur() + this.blurWithin() + handler?.({ target: ev.target }) + } + } + get onBlurLocal() { + return this._onBlurLocal + } + public get focusedLocal() { + return !(this.parent instanceof GridRenderable) || this.parent.currentFocusLocal === this + } + + /** Request local focus from the parent grid. */ + public focusLocal() { + if (!(this.parent instanceof GridRenderable)) return + this.parent.currentFocusLocal = this + } + + /** Drop local focus in the parent grid. */ + public blurLocal() { + if (!(this.parent instanceof GridRenderable)) return + this.parent.currentFocusLocal = undefined + } + + // === Key handling === + /** Wrap keydown to bubble to ancestors once local handlers run. */ + public override set onKeyDown(handler: ((key: KeyEvent) => void) | undefined) { + this.externalKeyDown = handler + + super.onKeyDown = (event: KeyEvent) => { + // Invoke external handler first, then bubble upward. + handler?.(event) + bubbleKeyDown(this, event) + } + } + + public override get onKeyDown() { + return this.externalKeyDown + } +} diff --git a/packages/core/src/renderables/grid/index.ts b/packages/core/src/renderables/grid/index.ts new file mode 100644 index 000000000..d0ae0afec --- /dev/null +++ b/packages/core/src/renderables/grid/index.ts @@ -0,0 +1,15 @@ +import { extend } from "@opentui/solid" +import GridRenderable from "./grid" +import GridNodeRenderable from "./grid_node" + +export const registerGridComponents = () => { + extend({ + grid: GridRenderable, + grid_node: GridNodeRenderable, + }) +} + +registerGridComponents() + +export { GridNodeRenderable, GridRenderable } +export * from "./types" diff --git a/packages/core/src/renderables/grid/sandbox.tsx b/packages/core/src/renderables/grid/sandbox.tsx new file mode 100644 index 000000000..326e806a9 --- /dev/null +++ b/packages/core/src/renderables/grid/sandbox.tsx @@ -0,0 +1,115 @@ +import { createSignal, createUniqueId, For, Index } from "solid-js" +import { RGBA } from "@opentui/core" +import GridRenderable from "./grid" +import { innerGridKeys, outerGridElements, outerGridKeys } from "./constants" + +const transparent = RGBA.fromInts(0, 0, 0, 0) + +function InnerGrid(props: { orientation: "vertical" | "horizontal"; id: string; coords: [number, number] }) { + const [innerItems, setInnerItems] = createSignal([0, 1, 2, 3]) + + return ( + { + switch (ev.name) { + case "space": + ev.stopPropagation() + + setInnerItems([0]) + break + } + }} + onFocus={(ev) => { + ev.target.currentFocusLocal?.focus() + }} + onFocusLocal={(ev) => { + ev.target.backgroundColor = "white" + ev.target.borderColor = "white" + }} + onBlurLocal={(ev) => { + ev.target.backgroundColor = transparent + ev.target.borderColor = transparent + }} + > + + {(item, i) => { + const slotId = createUniqueId() + + return ( + { + switch (ev.name) { + case "space": + ev.stopPropagation() + + setInnerItems((prev) => [...prev.slice(0, i), item(), ...prev.slice(i)]) + break + case "backspace": + setInnerItems((prev) => prev.filter((_, j) => i !== j)) + break + } + }} + onFocusLocal={(ev) => { + ev.target.backgroundColor = "green" + ev.target.borderColor = "green" + }} + onBlurLocal={(ev) => { + ev.target.backgroundColor = "red" + ev.target.borderColor = "red" + }} + > + + {item()}-{slotId} + + + ) + }} + + + ) +} + +export default function (props: { ref: GridRenderable }) { + return ( + + + {(item) => } + + + ) +} diff --git a/packages/core/src/renderables/grid/symbols.ts b/packages/core/src/renderables/grid/symbols.ts new file mode 100644 index 000000000..c0a2cd87c --- /dev/null +++ b/packages/core/src/renderables/grid/symbols.ts @@ -0,0 +1,4 @@ +import type { Renderable } from "@opentui/core"; + +export const prevFocusedRenderable = Symbol("prevFocusedRenderable"); +export type AddPrevFocusedRenderable = { [prevFocusedRenderable]?: Renderable | null }; diff --git a/packages/core/src/renderables/grid/types.ts b/packages/core/src/renderables/grid/types.ts new file mode 100644 index 000000000..b44ee6fd9 --- /dev/null +++ b/packages/core/src/renderables/grid/types.ts @@ -0,0 +1,81 @@ +import type { BoxOptions, BoxRenderable } from "@opentui/core" +import type GridRenderable from "./grid" +import type GridNodeRenderable from "./grid_node" + +// === Key configuration === +/** Modifier keys that must be held for a keybinding. */ +export type KeySettingsCondition = "ctrl" | "meta" | "shift" | "option" +/** How movement behaves when a direction runs out of bounds. */ +export type MovementOverflowBehavior = "stop" | "bubble" | "wrap-around" + +/** Key configuration for movement or actions. */ +export interface KeySettings { + name: string + conditions?: KeySettingsCondition[] + overflowBehavior?: MovementOverflowBehavior +} +export interface ActionKeySettings extends Omit {} + +/** Logical directions supported by grid navigation. */ +export type MovementDirection = "left" | "right" | "up" | "down" + +/** Movement bindings and defaults for overflow handling. */ +export interface GridMovement extends Partial> { + overflowBehavior?: MovementOverflowBehavior +} + +/** Complete keymap for grid navigation. */ +export type GridKeys = { + movement: GridMovement + actions?: { + focus: ActionKeySettings + unfocus: ActionKeySettings + } +} + +/** Grid-specific options combining node options with keymap. */ +export interface GridOptions extends GridNodeOptions { + keys: GridKeys +} + +// === Event typing === +/** Event handler type with bivariance for ergonomic callbacks. */ +export type EventHandler = { bivarianceHack(event: TEvent): void }["bivarianceHack"] + +/** Base focus event for grid nodes. */ +export interface FocusEvent { + target: TTarget +} + +/** Focus event for focus-within. */ +export interface FocusWithinEvent + extends FocusEvent {} + +/** Focus event for local focus inside a grid. */ +export interface FocusLocalEvent extends FocusEvent {} + +/** Base blur event for grid nodes. */ +export interface BlurEvent { + target: TTarget +} + +/** Blur event for focus-within. */ +export interface BlurWithinEvent extends BlurEvent {} + +/** Blur event for local focus inside a grid. */ +export interface BlurLocalEvent extends BlurEvent {} + +export type GridCoords = [number, number] + +// === Node options === +export interface GridNodeOptions + extends BoxOptions { + coords: [number, number] + onFocus?: EventHandler> + onFocusWithin?: EventHandler> + onFocusLocal?: EventHandler> + + onBlur?: EventHandler> + onBlurWithin?: EventHandler> + onBlurLocal?: EventHandler> +} diff --git a/packages/core/src/renderables/grid/utils.ts b/packages/core/src/renderables/grid/utils.ts new file mode 100644 index 000000000..9c7daa0f9 --- /dev/null +++ b/packages/core/src/renderables/grid/utils.ts @@ -0,0 +1,64 @@ +import type { KeyEvent, Renderable } from "@opentui/core" +import GridNodeRenderable from "./grid_node" +import type { KeySettingsCondition } from "./types" + +const bubbleKeyDownMarker = Symbol("bubbleKeyDown") + +/** + * Bubble a keydown event through renderable parents, avoiding repeated + * handler reentry while preserving event state. + */ +export const bubbleKeyDown = (start: GridNodeRenderable, event: KeyEvent) => { + if (event.defaultPrevented || event.propagationStopped) return + + let current = start.parent + + while (current) { + const handler = current.onKeyDown + + if (handler) { + const marked = (event as { [bubbleKeyDownMarker]?: boolean })[bubbleKeyDownMarker] + + // Mark to avoid re-entrancy and allow cleanup for top-level callers. + ;(event as { [bubbleKeyDownMarker]?: boolean })[bubbleKeyDownMarker] = true + + try { + handler(event) + } finally { + if (!marked) { + delete (event as { [bubbleKeyDownMarker]?: boolean })[bubbleKeyDownMarker] + } + } + + if (event.defaultPrevented || event.propagationStopped) return + } + + current = current.parent + } +} + +/** Walk ancestor renderables and invoke a callback for GridNodeRenderable nodes. */ +export const walkGridNodeAncestors = ( + start: Renderable | null | undefined, + end: Renderable | null, + visitor: (node: GridNodeRenderable) => void, +) => { + let current = start + + while (current && current !== end) { + if (!(current instanceof GridNodeRenderable)) { + current = current.parent + continue + } + + visitor(current) + current = current.parent + } +} + +const possibleConditions: KeySettingsCondition[] = ["ctrl", "shift", "meta", "option"] +/** Check if a KeyEvent matches conditions 1:1 */ +export const eventMatchesConditions = (ev: KeyEvent, conditions: KeySettingsCondition[] | undefined) => + possibleConditions.every((condition) => + ev[condition] ? conditions?.includes(condition) : !conditions?.includes(condition), + )