From 5d394271f49df8fcea9730ed45b18ae0acdf2bbb Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Wed, 3 Sep 2025 17:48:21 +0200 Subject: [PATCH 01/26] tabindex --- packages/core/src/Renderable.ts | 6 + .../src/examples/input-select-layout-demo.ts | 37 ----- packages/core/src/lib/FocusManager.ts | 105 ++++++++++++++ packages/core/src/lib/YGTreeWalker.ts | 130 ++++++++++++++++++ packages/core/src/lib/index.ts | 1 + packages/core/src/renderer.ts | 16 ++- packages/core/src/types.ts | 5 + 7 files changed, 258 insertions(+), 42 deletions(-) create mode 100644 packages/core/src/lib/FocusManager.ts create mode 100644 packages/core/src/lib/YGTreeWalker.ts diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 3db7f1516..4a402c9ce 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -972,6 +972,8 @@ export abstract class Renderable extends EventEmitter { this.requestRender() + this._ctx.focusManager?.detachWalker() + return insertedIndex } @@ -1023,6 +1025,8 @@ export abstract class Renderable extends EventEmitter { if (index !== -1) { this.renderableArray.splice(index, 1) } + + this._ctx.focusManager?.detachWalker() } } @@ -1127,6 +1131,8 @@ export abstract class Renderable extends EventEmitter { this.blur() this.removeAllListeners() + this._ctx.focusManager?.detachWalker() + this.destroySelf() } diff --git a/packages/core/src/examples/input-select-layout-demo.ts b/packages/core/src/examples/input-select-layout-demo.ts index e3e10f1a8..ce791df93 100644 --- a/packages/core/src/examples/input-select-layout-demo.ts +++ b/packages/core/src/examples/input-select-layout-demo.ts @@ -22,9 +22,6 @@ let footer: TextRenderable | null = null let footerBox: BoxRenderable | null = null let currentFocusIndex = 0 -const focusableElements: Array = [] -const focusableBoxes: Array = [] - const colorOptions: SelectOption[] = [ { name: "Red", description: "A warm primary color", value: "#ff0000" }, { name: "Blue", description: "A cool primary color", value: "#0066ff" }, @@ -282,10 +279,7 @@ function createLayoutElements(rendererInstance: CliRenderer): void { renderer.root.add(inputContainerBox) renderer.root.add(footerBox) - focusableElements.push(leftSelect, rightSelect, textInput) - focusableBoxes.push(leftSelectBox, rightSelectBox, textInputBox) setupEventHandlers() - updateFocus() renderer.on("resize", handleResize) } @@ -343,41 +337,12 @@ function handleResize(width: number, height: number): void { // Root layout is automatically resized by the renderer } -function updateFocus(): void { - focusableElements.forEach((element) => element.blur()) - focusableBoxes.forEach((box) => { - if (box) box.blur() - }) - - if (focusableElements[currentFocusIndex]) { - focusableElements[currentFocusIndex].focus() - } - if (focusableBoxes[currentFocusIndex]) { - focusableBoxes[currentFocusIndex]!.focus() - } -} - -function handleKeyPress(key: ParsedKey): void { - if (key.name === "tab") { - if (key.shift) { - currentFocusIndex = (currentFocusIndex - 1 + focusableElements.length) % focusableElements.length - } else { - currentFocusIndex = (currentFocusIndex + 1) % focusableElements.length - } - updateFocus() - return - } -} - export function run(rendererInstance: CliRenderer): void { createLayoutElements(rendererInstance) - getKeyHandler().on("keypress", handleKeyPress) updateDisplay() } export function destroy(rendererInstance: CliRenderer): void { - getKeyHandler().off("keypress", handleKeyPress) - if (renderer) { renderer.off("resize", handleResize) } @@ -411,8 +376,6 @@ export function destroy(rendererInstance: CliRenderer): void { footerBox = null renderer = null currentFocusIndex = 0 - focusableElements.length = 0 - focusableBoxes.length = 0 } if (import.meta.main) { diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts new file mode 100644 index 000000000..1681efa05 --- /dev/null +++ b/packages/core/src/lib/FocusManager.ts @@ -0,0 +1,105 @@ +import { getKeyHandler } from "./KeyHandler" +import type { Renderable } from "../Renderable" +import { YGTreeWalker } from "./YGTreeWalker" +import type { ParsedKey } from "./parse.keypress" + +export class FocusManager { + private static instance: FocusManager | null = null + + private keyUnsubscribe: (() => void) | null = null + private root: Renderable | null = null + private current: Renderable | null = null + private walker: YGTreeWalker | null = null; + + static install(root: Renderable): FocusManager { + if (this.instance) return this.instance + this.instance = new FocusManager(root) + this.instance.attach() + this.instance.findFirstFocusable() + return this.instance + } + + constructor(root: Renderable) { + this.root = root + } + + private createWalker(): YGTreeWalker | null { + if (!this.root) return null + return new YGTreeWalker(this.root, (n) => this.isFocusable(n)) + } + + private getWalker(): YGTreeWalker { + if (!this.walker) this.walker = this.createWalker(); + if (this.current && this.walker) this.walker.currentNode = this.current; + return this.walker!; + } + + public detachWalker(): void { + this.walker = null + } + + static uninstall(): void { + this.instance?.detach() + this.instance = null + } + + private attach(): void { + const keyHandler = getKeyHandler() + const keypress = (key: ParsedKey) => { + if (key.name === "tab") { + key.shift ? this.focusPrev() : this.focusNext() + } + console.log(this.current); + + } + keyHandler.on("keypress", keypress) + this.keyUnsubscribe = () => keyHandler.off("keypress", keypress) + } + + private detach(): void { + this.keyUnsubscribe?.() + this.keyUnsubscribe = null + this.current = null + this.walker = null + } + + private isFocusable(r: Renderable): boolean { return r["focusable"] === true && r.visible === true } + + private findFirstFocusable(): Renderable | null { + const walker = this.getWalker() + if (!walker) return null + return walker.firstAccepted() + } + + private findNextFocusable(): Renderable | null { + const walker = this.getWalker() + if (!walker) return null + const next = walker.nextAccepted() + return next ?? walker.firstAccepted() + } + + private focusNext() { + const next = this.findNextFocusable() + if (!next) return + + if (this.current) this.current.blur() + this.current = next + this.current.focus() + } + + private findPrevFocusable(): Renderable | null { + const walker = this.getWalker() + if (!walker) return null + const prev = walker.prevAccepted() + return prev ?? walker.lastAccepted() + } + + private focusPrev() { + const prev = this.findPrevFocusable() + if (!prev) return + + if (this.current) this.current.blur() + this.current = prev + this.current.focus() + } +} diff --git a/packages/core/src/lib/YGTreeWalker.ts b/packages/core/src/lib/YGTreeWalker.ts new file mode 100644 index 000000000..b0b7372da --- /dev/null +++ b/packages/core/src/lib/YGTreeWalker.ts @@ -0,0 +1,130 @@ +import type { Renderable } from "../Renderable" + +export type YGAcceptFn = (node: Renderable) => boolean + +export class YGTreeWalker { + public readonly root: Renderable + private _current: Renderable + private readonly accept?: YGAcceptFn + + constructor(root: Renderable, accept?: YGAcceptFn) { + this.root = root + this._current = root + this.accept = accept + } + + public get currentNode(): Renderable { + return this._current + } + + public set currentNode(node: Renderable) { + this._current = node + } + + private getParent(node: Renderable): Renderable | null { + return node.parent || null + } + + private getFirstChild(node: Renderable): Renderable | null { + const children = node.getChildren() + return children.length > 0 ? children[0] : null + } + + private getLastChild(node: Renderable): Renderable | null { + const children = node.getChildren() + return children.length > 0 ? children[children.length - 1] : null + } + + private getNextSibling(node: Renderable): Renderable | null { + const parent = this.getParent(node) + if (!parent) return null + const siblings = parent.getChildren() + const idx = siblings.indexOf(node) + if (idx === -1) return null + return idx + 1 < siblings.length ? siblings[idx + 1] : null + } + + private getPrevSibling(node: Renderable): Renderable | null { + const parent = this.getParent(node) + if (!parent) return null + const siblings = parent.getChildren() + const idx = siblings.indexOf(node) + if (idx <= 0) return null + return siblings[idx - 1] + } + + private nextRaw(from: Renderable): Renderable | null { + const child = this.getFirstChild(from) + if (child) return child + let node: Renderable | null = from + while (node) { + const sibling = this.getNextSibling(node) + if (sibling) return sibling + node = this.getParent(node) + } + return null + } + + private prevRaw(from: Renderable): Renderable | null { + const prevSibling = this.getPrevSibling(from) + if (prevSibling) { + let deepest: Renderable = prevSibling + for (; ;) { + const child = this.getLastChild(deepest) + if (!child) break + deepest = child + } + return deepest + } + const parent = this.getParent(from) + return parent + } + + public firstAccepted(): Renderable | null { + const stack: Renderable[] = [this.root] + while (stack.length > 0) { + const node = stack.shift() as Renderable + if (!this.accept || this.accept(node)) return node + const children = node.getChildren() + for (let i = 0; i < children.length; i++) { + stack.splice(i, 0, children[i]) + } + } + return null + } + + public lastAccepted(): Renderable | null { + let node: Renderable | null = this.root + // descend to the deepest last + while (true) { + const lastChild: Renderable | null = node ? this.getLastChild(node) : null + if (!lastChild) break + node = lastChild + } + // climb backwards until accepted + while (node) { + if (!this.accept || this.accept(node)) return node + node = this.prevRaw(node) + } + return null + } + + public nextAccepted(): Renderable | null { + let node: Renderable | null = this._current + while (true) { + node = node ? this.nextRaw(node) : null + if (!node) return null + if (!this.accept || this.accept(node)) return node + } + } + + public prevAccepted(): Renderable | null { + let node: Renderable | null = this._current + while (true) { + node = node ? this.prevRaw(node) : null + if (!node) return null + if (!this.accept || this.accept(node)) return node + } + } +} + diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 52bb4a75c..c3aff7cf5 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -9,3 +9,4 @@ export * from "./styled-text" export * from "./yoga.options" export * from "./parse.mouse" export * from "./selection" +export * from "./FocusManager" diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 61729e118..817d87037 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -6,6 +6,7 @@ import { type RenderContext, type SelectionState, type WidthMethod, + type FocusController, } from "./types" import { RGBA, parseColor, type ColorInput } from "./lib/RGBA" import type { Pointer } from "bun:ffi" @@ -16,6 +17,7 @@ import { MouseParser, type MouseEventType, type RawMouseEvent, type ScrollInfo } import { Selection } from "./lib/selection" import { EventEmitter } from "events" import { singleton } from "./singleton" +import { FocusManager } from "./lib/FocusManager" export interface CliRendererConfig { stdin?: NodeJS.ReadStream @@ -122,6 +124,9 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise const renderer = new CliRenderer(ziglib, rendererPtr, stdin, stdout, width, height, config) await renderer.setupTerminal() + // Install default keyboard navigation (Tab/Shift+Tab) + const fm = FocusManager.install(renderer.root) + ; (renderer as any).focusManager = fm as FocusController return renderer } @@ -188,11 +193,11 @@ export class CliRenderer extends EventEmitter implements RenderContext { renderTime?: number frameCallbackTime: number } = { - frameCount: 0, - fps: 0, - renderTime: 0, - frameCallbackTime: 0, - } + frameCount: 0, + fps: 0, + renderTime: 0, + frameCallbackTime: 0, + } public debugOverlay = { enabled: false, corner: DebugOverlayCorner.bottomRight, @@ -235,6 +240,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private mouseParser: MouseParser = new MouseParser() private sigwinchHandler: (() => void) | null = null private _capabilities: any | null = null + public focusManager?: FocusController constructor( lib: RenderLib, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6d1e967bd..58de5f8a7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -23,6 +23,10 @@ export enum DebugOverlayCorner { export type WidthMethod = "wcwidth" | "unicode" +export interface FocusController { + detachWalker: () => void +} + export interface RenderContext { addToHitGrid: (x: number, y: number, width: number, height: number, id: number) => void width: number @@ -35,6 +39,7 @@ export interface RenderContext { capabilities: any | null requestLive: () => void dropLive: () => void + focusManager?: FocusController } export interface SelectionState { From f03e13c52c9f8d0a9071fef8585a3f1e636e3a63 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Wed, 3 Sep 2025 17:51:37 +0200 Subject: [PATCH 02/26] Remove console log in FocusManager --- packages/core/src/lib/FocusManager.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index 1681efa05..a8080dd1d 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -49,8 +49,6 @@ export class FocusManager { if (key.name === "tab") { key.shift ? this.focusPrev() : this.focusNext() } - console.log(this.current); - } keyHandler.on("keypress", keypress) this.keyUnsubscribe = () => keyHandler.off("keypress", keypress) From 5d914258ea7375dbef5e83a358dded01f79afccd Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Wed, 3 Sep 2025 17:52:11 +0200 Subject: [PATCH 03/26] Run prettier --- packages/core/src/Renderable.ts | 2 +- packages/core/src/lib/FocusManager.ts | 192 ++++++++++----------- packages/core/src/lib/YGTreeWalker.ts | 233 +++++++++++++------------- packages/core/src/renderer.ts | 12 +- 4 files changed, 220 insertions(+), 219 deletions(-) diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 4a402c9ce..d34f19925 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -1132,7 +1132,7 @@ export abstract class Renderable extends EventEmitter { this.removeAllListeners() this._ctx.focusManager?.detachWalker() - + this.destroySelf() } diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index a8080dd1d..23f1182da 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -4,100 +4,102 @@ import { YGTreeWalker } from "./YGTreeWalker" import type { ParsedKey } from "./parse.keypress" export class FocusManager { - private static instance: FocusManager | null = null - - private keyUnsubscribe: (() => void) | null = null - private root: Renderable | null = null - private current: Renderable | null = null - private walker: YGTreeWalker | null = null; - - static install(root: Renderable): FocusManager { - if (this.instance) return this.instance - this.instance = new FocusManager(root) - this.instance.attach() - this.instance.findFirstFocusable() - return this.instance - } - - constructor(root: Renderable) { - this.root = root - } - - private createWalker(): YGTreeWalker | null { - if (!this.root) return null - return new YGTreeWalker(this.root, (n) => this.isFocusable(n)) - } - - private getWalker(): YGTreeWalker { - if (!this.walker) this.walker = this.createWalker(); - if (this.current && this.walker) this.walker.currentNode = this.current; - return this.walker!; - } - - public detachWalker(): void { - this.walker = null - } - - static uninstall(): void { - this.instance?.detach() - this.instance = null - } - - private attach(): void { - const keyHandler = getKeyHandler() - const keypress = (key: ParsedKey) => { - if (key.name === "tab") { - key.shift ? this.focusPrev() : this.focusNext() - } - } - keyHandler.on("keypress", keypress) - this.keyUnsubscribe = () => keyHandler.off("keypress", keypress) - } - - private detach(): void { - this.keyUnsubscribe?.() - this.keyUnsubscribe = null - this.current = null - this.walker = null - } - - private isFocusable(r: Renderable): boolean { return r["focusable"] === true && r.visible === true } - - private findFirstFocusable(): Renderable | null { - const walker = this.getWalker() - if (!walker) return null - return walker.firstAccepted() - } - - private findNextFocusable(): Renderable | null { - const walker = this.getWalker() - if (!walker) return null - const next = walker.nextAccepted() - return next ?? walker.firstAccepted() - } - - private focusNext() { - const next = this.findNextFocusable() - if (!next) return - - if (this.current) this.current.blur() - this.current = next - this.current.focus() - } - - private findPrevFocusable(): Renderable | null { - const walker = this.getWalker() - if (!walker) return null - const prev = walker.prevAccepted() - return prev ?? walker.lastAccepted() - } - - private focusPrev() { - const prev = this.findPrevFocusable() - if (!prev) return - - if (this.current) this.current.blur() - this.current = prev - this.current.focus() + private static instance: FocusManager | null = null + + private keyUnsubscribe: (() => void) | null = null + private root: Renderable | null = null + private current: Renderable | null = null + private walker: YGTreeWalker | null = null + + static install(root: Renderable): FocusManager { + if (this.instance) return this.instance + this.instance = new FocusManager(root) + this.instance.attach() + this.instance.findFirstFocusable() + return this.instance + } + + constructor(root: Renderable) { + this.root = root + } + + private createWalker(): YGTreeWalker | null { + if (!this.root) return null + return new YGTreeWalker(this.root, (n) => this.isFocusable(n)) + } + + private getWalker(): YGTreeWalker { + if (!this.walker) this.walker = this.createWalker() + if (this.current && this.walker) this.walker.currentNode = this.current + return this.walker! + } + + public detachWalker(): void { + this.walker = null + } + + static uninstall(): void { + this.instance?.detach() + this.instance = null + } + + private attach(): void { + const keyHandler = getKeyHandler() + const keypress = (key: ParsedKey) => { + if (key.name === "tab") { + key.shift ? this.focusPrev() : this.focusNext() + } } + keyHandler.on("keypress", keypress) + this.keyUnsubscribe = () => keyHandler.off("keypress", keypress) + } + + private detach(): void { + this.keyUnsubscribe?.() + this.keyUnsubscribe = null + this.current = null + this.walker = null + } + + private isFocusable(r: Renderable): boolean { + return r["focusable"] === true && r.visible === true + } + + private findFirstFocusable(): Renderable | null { + const walker = this.getWalker() + if (!walker) return null + return walker.firstAccepted() + } + + private findNextFocusable(): Renderable | null { + const walker = this.getWalker() + if (!walker) return null + const next = walker.nextAccepted() + return next ?? walker.firstAccepted() + } + + private focusNext() { + const next = this.findNextFocusable() + if (!next) return + + if (this.current) this.current.blur() + this.current = next + this.current.focus() + } + + private findPrevFocusable(): Renderable | null { + const walker = this.getWalker() + if (!walker) return null + const prev = walker.prevAccepted() + return prev ?? walker.lastAccepted() + } + + private focusPrev() { + const prev = this.findPrevFocusable() + if (!prev) return + + if (this.current) this.current.blur() + this.current = prev + this.current.focus() + } } diff --git a/packages/core/src/lib/YGTreeWalker.ts b/packages/core/src/lib/YGTreeWalker.ts index b0b7372da..73c1b1fb6 100644 --- a/packages/core/src/lib/YGTreeWalker.ts +++ b/packages/core/src/lib/YGTreeWalker.ts @@ -3,128 +3,127 @@ import type { Renderable } from "../Renderable" export type YGAcceptFn = (node: Renderable) => boolean export class YGTreeWalker { - public readonly root: Renderable - private _current: Renderable - private readonly accept?: YGAcceptFn - - constructor(root: Renderable, accept?: YGAcceptFn) { - this.root = root - this._current = root - this.accept = accept + public readonly root: Renderable + private _current: Renderable + private readonly accept?: YGAcceptFn + + constructor(root: Renderable, accept?: YGAcceptFn) { + this.root = root + this._current = root + this.accept = accept + } + + public get currentNode(): Renderable { + return this._current + } + + public set currentNode(node: Renderable) { + this._current = node + } + + private getParent(node: Renderable): Renderable | null { + return node.parent || null + } + + private getFirstChild(node: Renderable): Renderable | null { + const children = node.getChildren() + return children.length > 0 ? children[0] : null + } + + private getLastChild(node: Renderable): Renderable | null { + const children = node.getChildren() + return children.length > 0 ? children[children.length - 1] : null + } + + private getNextSibling(node: Renderable): Renderable | null { + const parent = this.getParent(node) + if (!parent) return null + const siblings = parent.getChildren() + const idx = siblings.indexOf(node) + if (idx === -1) return null + return idx + 1 < siblings.length ? siblings[idx + 1] : null + } + + private getPrevSibling(node: Renderable): Renderable | null { + const parent = this.getParent(node) + if (!parent) return null + const siblings = parent.getChildren() + const idx = siblings.indexOf(node) + if (idx <= 0) return null + return siblings[idx - 1] + } + + private nextRaw(from: Renderable): Renderable | null { + const child = this.getFirstChild(from) + if (child) return child + let node: Renderable | null = from + while (node) { + const sibling = this.getNextSibling(node) + if (sibling) return sibling + node = this.getParent(node) } - - public get currentNode(): Renderable { - return this._current + return null + } + + private prevRaw(from: Renderable): Renderable | null { + const prevSibling = this.getPrevSibling(from) + if (prevSibling) { + let deepest: Renderable = prevSibling + for (;;) { + const child = this.getLastChild(deepest) + if (!child) break + deepest = child + } + return deepest } - - public set currentNode(node: Renderable) { - this._current = node + const parent = this.getParent(from) + return parent + } + + public firstAccepted(): Renderable | null { + const stack: Renderable[] = [this.root] + while (stack.length > 0) { + const node = stack.shift() as Renderable + if (!this.accept || this.accept(node)) return node + const children = node.getChildren() + for (let i = 0; i < children.length; i++) { + stack.splice(i, 0, children[i]) + } } - - private getParent(node: Renderable): Renderable | null { - return node.parent || null + return null + } + + public lastAccepted(): Renderable | null { + let node: Renderable | null = this.root + // descend to the deepest last + while (true) { + const lastChild: Renderable | null = node ? this.getLastChild(node) : null + if (!lastChild) break + node = lastChild } - - private getFirstChild(node: Renderable): Renderable | null { - const children = node.getChildren() - return children.length > 0 ? children[0] : null + // climb backwards until accepted + while (node) { + if (!this.accept || this.accept(node)) return node + node = this.prevRaw(node) } - - private getLastChild(node: Renderable): Renderable | null { - const children = node.getChildren() - return children.length > 0 ? children[children.length - 1] : null + return null + } + + public nextAccepted(): Renderable | null { + let node: Renderable | null = this._current + while (true) { + node = node ? this.nextRaw(node) : null + if (!node) return null + if (!this.accept || this.accept(node)) return node } - - private getNextSibling(node: Renderable): Renderable | null { - const parent = this.getParent(node) - if (!parent) return null - const siblings = parent.getChildren() - const idx = siblings.indexOf(node) - if (idx === -1) return null - return idx + 1 < siblings.length ? siblings[idx + 1] : null - } - - private getPrevSibling(node: Renderable): Renderable | null { - const parent = this.getParent(node) - if (!parent) return null - const siblings = parent.getChildren() - const idx = siblings.indexOf(node) - if (idx <= 0) return null - return siblings[idx - 1] - } - - private nextRaw(from: Renderable): Renderable | null { - const child = this.getFirstChild(from) - if (child) return child - let node: Renderable | null = from - while (node) { - const sibling = this.getNextSibling(node) - if (sibling) return sibling - node = this.getParent(node) - } - return null - } - - private prevRaw(from: Renderable): Renderable | null { - const prevSibling = this.getPrevSibling(from) - if (prevSibling) { - let deepest: Renderable = prevSibling - for (; ;) { - const child = this.getLastChild(deepest) - if (!child) break - deepest = child - } - return deepest - } - const parent = this.getParent(from) - return parent - } - - public firstAccepted(): Renderable | null { - const stack: Renderable[] = [this.root] - while (stack.length > 0) { - const node = stack.shift() as Renderable - if (!this.accept || this.accept(node)) return node - const children = node.getChildren() - for (let i = 0; i < children.length; i++) { - stack.splice(i, 0, children[i]) - } - } - return null - } - - public lastAccepted(): Renderable | null { - let node: Renderable | null = this.root - // descend to the deepest last - while (true) { - const lastChild: Renderable | null = node ? this.getLastChild(node) : null - if (!lastChild) break - node = lastChild - } - // climb backwards until accepted - while (node) { - if (!this.accept || this.accept(node)) return node - node = this.prevRaw(node) - } - return null - } - - public nextAccepted(): Renderable | null { - let node: Renderable | null = this._current - while (true) { - node = node ? this.nextRaw(node) : null - if (!node) return null - if (!this.accept || this.accept(node)) return node - } - } - - public prevAccepted(): Renderable | null { - let node: Renderable | null = this._current - while (true) { - node = node ? this.prevRaw(node) : null - if (!node) return null - if (!this.accept || this.accept(node)) return node - } + } + + public prevAccepted(): Renderable | null { + let node: Renderable | null = this._current + while (true) { + node = node ? this.prevRaw(node) : null + if (!node) return null + if (!this.accept || this.accept(node)) return node } + } } - diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 817d87037..b9d8f31ca 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -126,7 +126,7 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise await renderer.setupTerminal() // Install default keyboard navigation (Tab/Shift+Tab) const fm = FocusManager.install(renderer.root) - ; (renderer as any).focusManager = fm as FocusController + ;(renderer as any).focusManager = fm as FocusController return renderer } @@ -193,11 +193,11 @@ export class CliRenderer extends EventEmitter implements RenderContext { renderTime?: number frameCallbackTime: number } = { - frameCount: 0, - fps: 0, - renderTime: 0, - frameCallbackTime: 0, - } + frameCount: 0, + fps: 0, + renderTime: 0, + frameCallbackTime: 0, + } public debugOverlay = { enabled: false, corner: DebugOverlayCorner.bottomRight, From 6d874443f8f504001a8411c5af9523af8d99d8cd Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 12:36:56 +0200 Subject: [PATCH 04/26] Update the YGTreeWalker to reset its current node on tree changes --- packages/core/src/Renderable.ts | 6 -- packages/core/src/examples/index.ts | 3 +- packages/core/src/lib/FocusManager.ts | 54 ++++++++--------- packages/core/src/lib/TrackedNode.ts | 6 ++ packages/core/src/lib/YGTreeWalker.ts | 83 ++++++++++++++++----------- packages/core/src/renderer.ts | 7 +-- packages/core/src/types.ts | 5 -- 7 files changed, 86 insertions(+), 78 deletions(-) diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index d34f19925..3db7f1516 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -972,8 +972,6 @@ export abstract class Renderable extends EventEmitter { this.requestRender() - this._ctx.focusManager?.detachWalker() - return insertedIndex } @@ -1025,8 +1023,6 @@ export abstract class Renderable extends EventEmitter { if (index !== -1) { this.renderableArray.splice(index, 1) } - - this._ctx.focusManager?.detachWalker() } } @@ -1131,8 +1127,6 @@ export abstract class Renderable extends EventEmitter { this.blur() this.removeAllListeners() - this._ctx.focusManager?.detachWalker() - this.destroySelf() } diff --git a/packages/core/src/examples/index.ts b/packages/core/src/examples/index.ts index ac4d2bf7a..784173951 100644 --- a/packages/core/src/examples/index.ts +++ b/packages/core/src/examples/index.ts @@ -405,7 +405,6 @@ class ExampleSelector { case "\u0003": this.cleanup() process.exit() - break } switch (key.name) { case "c": @@ -447,6 +446,7 @@ class ExampleSelector { this.selectBox.visible = false } if (this.selectElement) { + this.selectElement.visible = false this.selectElement.blur() } } @@ -458,6 +458,7 @@ class ExampleSelector { this.selectBox.visible = true } if (this.selectElement) { + this.selectElement.visible = true this.selectElement.focus() } } diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index 23f1182da..f1ad4f749 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -7,42 +7,34 @@ export class FocusManager { private static instance: FocusManager | null = null private keyUnsubscribe: (() => void) | null = null - private root: Renderable | null = null + private readonly root: Renderable private current: Renderable | null = null private walker: YGTreeWalker | null = null static install(root: Renderable): FocusManager { if (this.instance) return this.instance - this.instance = new FocusManager(root) - this.instance.attach() - this.instance.findFirstFocusable() - return this.instance + const mgr = new FocusManager(root) + this.instance = mgr + mgr.attach() + mgr.initFocus() + return mgr } - constructor(root: Renderable) { - this.root = root + static uninstall(): void { + this.instance?.detach() + this.instance = null } - private createWalker(): YGTreeWalker | null { - if (!this.root) return null - return new YGTreeWalker(this.root, (n) => this.isFocusable(n)) + constructor(root: Renderable) { + this.root = root + this.walker = new YGTreeWalker(this.root, (n) => this.isFocusable(n)) } private getWalker(): YGTreeWalker { - if (!this.walker) this.walker = this.createWalker() if (this.current && this.walker) this.walker.currentNode = this.current return this.walker! } - public detachWalker(): void { - this.walker = null - } - - static uninstall(): void { - this.instance?.detach() - this.instance = null - } - private attach(): void { const keyHandler = getKeyHandler() const keypress = (key: ParsedKey) => { @@ -62,18 +54,23 @@ export class FocusManager { } private isFocusable(r: Renderable): boolean { - return r["focusable"] === true && r.visible === true + const is = r["focusable"] === true && r["_visible"] === true + console.log(is, r["id"]) + + return r["focusable"] === true && r["_visible"] === true } - private findFirstFocusable(): Renderable | null { + private initFocus() { const walker = this.getWalker() - if (!walker) return null - return walker.firstAccepted() + const first = walker.firstAccepted() + if (first) { + this.current = first + first.focus() + } } private findNextFocusable(): Renderable | null { const walker = this.getWalker() - if (!walker) return null const next = walker.nextAccepted() return next ?? walker.firstAccepted() } @@ -81,15 +78,13 @@ export class FocusManager { private focusNext() { const next = this.findNextFocusable() if (!next) return - - if (this.current) this.current.blur() + this.current?.blur() this.current = next this.current.focus() } private findPrevFocusable(): Renderable | null { const walker = this.getWalker() - if (!walker) return null const prev = walker.prevAccepted() return prev ?? walker.lastAccepted() } @@ -97,8 +92,7 @@ export class FocusManager { private focusPrev() { const prev = this.findPrevFocusable() if (!prev) return - - if (this.current) this.current.blur() + this.current?.blur() this.current = prev this.current.focus() } diff --git a/packages/core/src/lib/TrackedNode.ts b/packages/core/src/lib/TrackedNode.ts index 2782f6ccf..ee0dbeff2 100644 --- a/packages/core/src/lib/TrackedNode.ts +++ b/packages/core/src/lib/TrackedNode.ts @@ -105,6 +105,7 @@ class TrackedNode extends EventEmitter { console.error("Error setting width and height", e) } + this.emit("treeChanged", this) return index } @@ -123,6 +124,7 @@ class TrackedNode extends EventEmitter { childNode.parent = null + this.emit("treeChanged", this) return true } @@ -138,6 +140,7 @@ class TrackedNode extends EventEmitter { childNode.parent = null + this.emit("treeChanged", this) return childNode } @@ -159,6 +162,7 @@ class TrackedNode extends EventEmitter { this.yogaNode.removeChild(childNode.yogaNode) this.yogaNode.insertChild(childNode.yogaNode, boundedNewIndex) + this.emit("treeChanged", this) return boundedNewIndex } @@ -180,6 +184,7 @@ class TrackedNode extends EventEmitter { console.error("Error setting width and height", e) } + this.emit("treeChanged", this) return boundedIndex } @@ -222,6 +227,7 @@ class TrackedNode extends EventEmitter { } catch (e) { // Might be already freed and will throw an error if we try to free it again } + this.emit("treeChanged", this) this._destroyed = true } } diff --git a/packages/core/src/lib/YGTreeWalker.ts b/packages/core/src/lib/YGTreeWalker.ts index 73c1b1fb6..8fdf43721 100644 --- a/packages/core/src/lib/YGTreeWalker.ts +++ b/packages/core/src/lib/YGTreeWalker.ts @@ -1,16 +1,25 @@ import type { Renderable } from "../Renderable" - -export type YGAcceptFn = (node: Renderable) => boolean +import type { TrackedNode } from "./TrackedNode" export class YGTreeWalker { public readonly root: Renderable + public readonly rootNode: TrackedNode private _current: Renderable - private readonly accept?: YGAcceptFn + private readonly accept?: (node: Renderable) => boolean - constructor(root: Renderable, accept?: YGAcceptFn) { + constructor(root: Renderable, accept?: (node: Renderable) => boolean) { this.root = root this._current = root this.accept = accept + this.rootNode = this.root.getLayoutNode() + + this.rootNode.on("treeChanged", () => { + this.reset() + }) + } + + public reset() { + this._current = this.root } public get currentNode(): Renderable { @@ -21,13 +30,21 @@ export class YGTreeWalker { this._current = node } + private isAccepted(node: Renderable): boolean { + return this.accept ? this.accept(node) : true + } + private getParent(node: Renderable): Renderable | null { return node.parent || null } - private getFirstChild(node: Renderable): Renderable | null { + private getChildAt(node: Renderable, index: number): Renderable | null { const children = node.getChildren() - return children.length > 0 ? children[0] : null + return children[index] ?? null + } + + private getFirstChild(node: Renderable): Renderable | null { + return this.getChildAt(node, 0) } private getLastChild(node: Renderable): Renderable | null { @@ -40,8 +57,7 @@ export class YGTreeWalker { if (!parent) return null const siblings = parent.getChildren() const idx = siblings.indexOf(node) - if (idx === -1) return null - return idx + 1 < siblings.length ? siblings[idx + 1] : null + return idx >= 0 && idx + 1 < siblings.length ? siblings[idx + 1] : null } private getPrevSibling(node: Renderable): Renderable | null { @@ -49,8 +65,7 @@ export class YGTreeWalker { if (!parent) return null const siblings = parent.getChildren() const idx = siblings.indexOf(node) - if (idx <= 0) return null - return siblings[idx - 1] + return idx > 0 ? siblings[idx - 1] : null } private nextRaw(from: Renderable): Renderable | null { @@ -69,61 +84,65 @@ export class YGTreeWalker { const prevSibling = this.getPrevSibling(from) if (prevSibling) { let deepest: Renderable = prevSibling - for (;;) { + while (true) { const child = this.getLastChild(deepest) if (!child) break deepest = child } return deepest } - const parent = this.getParent(from) - return parent + return this.getParent(from) + } + + private *traverseForward(from: Renderable): Generator { + let node: Renderable | null = from + while ((node = this.nextRaw(node))) { + yield node + } + } + + private *traverseBackward(from: Renderable): Generator { + let node: Renderable | null = from + while ((node = this.prevRaw(node))) { + yield node + } } public firstAccepted(): Renderable | null { const stack: Renderable[] = [this.root] while (stack.length > 0) { - const node = stack.shift() as Renderable - if (!this.accept || this.accept(node)) return node - const children = node.getChildren() - for (let i = 0; i < children.length; i++) { - stack.splice(i, 0, children[i]) - } + const node = stack.pop()! + if (this.isAccepted(node)) return node + stack.push(...node.getChildren().reverse()) } return null } public lastAccepted(): Renderable | null { let node: Renderable | null = this.root - // descend to the deepest last while (true) { const lastChild: Renderable | null = node ? this.getLastChild(node) : null if (!lastChild) break node = lastChild } - // climb backwards until accepted while (node) { - if (!this.accept || this.accept(node)) return node + if (this.isAccepted(node)) return node node = this.prevRaw(node) } return null } public nextAccepted(): Renderable | null { - let node: Renderable | null = this._current - while (true) { - node = node ? this.nextRaw(node) : null - if (!node) return null - if (!this.accept || this.accept(node)) return node + for (const node of this.traverseForward(this._current)) { + if (this.isAccepted(node)) return node } + return null } public prevAccepted(): Renderable | null { - let node: Renderable | null = this._current - while (true) { - node = node ? this.prevRaw(node) : null - if (!node) return null - if (!this.accept || this.accept(node)) return node + for (const node of this.traverseBackward(this._current)) { + if (this.isAccepted(node)) return node } + return null } } diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index b9d8f31ca..276e0c856 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -6,7 +6,6 @@ import { type RenderContext, type SelectionState, type WidthMethod, - type FocusController, } from "./types" import { RGBA, parseColor, type ColorInput } from "./lib/RGBA" import type { Pointer } from "bun:ffi" @@ -124,9 +123,10 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise const renderer = new CliRenderer(ziglib, rendererPtr, stdin, stdout, width, height, config) await renderer.setupTerminal() + // Install default keyboard navigation (Tab/Shift+Tab) - const fm = FocusManager.install(renderer.root) - ;(renderer as any).focusManager = fm as FocusController + FocusManager.install(renderer.root) + return renderer } @@ -240,7 +240,6 @@ export class CliRenderer extends EventEmitter implements RenderContext { private mouseParser: MouseParser = new MouseParser() private sigwinchHandler: (() => void) | null = null private _capabilities: any | null = null - public focusManager?: FocusController constructor( lib: RenderLib, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 58de5f8a7..6d1e967bd 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -23,10 +23,6 @@ export enum DebugOverlayCorner { export type WidthMethod = "wcwidth" | "unicode" -export interface FocusController { - detachWalker: () => void -} - export interface RenderContext { addToHitGrid: (x: number, y: number, width: number, height: number, id: number) => void width: number @@ -39,7 +35,6 @@ export interface RenderContext { capabilities: any | null requestLive: () => void dropLive: () => void - focusManager?: FocusController } export interface SelectionState { From 0fe4ec35f325c6cce40d3386c7daad21a0b52dd9 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 12:39:59 +0200 Subject: [PATCH 05/26] Remove unused console log --- packages/core/src/lib/FocusManager.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index f1ad4f749..92d7769e2 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -54,9 +54,6 @@ export class FocusManager { } private isFocusable(r: Renderable): boolean { - const is = r["focusable"] === true && r["_visible"] === true - console.log(is, r["id"]) - return r["focusable"] === true && r["_visible"] === true } From 7d704829b77ef62e058a3d15877225111d04634f Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 12:57:53 +0200 Subject: [PATCH 06/26] remove unused imports and variables in input-select-layout-demo --- packages/core/src/examples/input-select-layout-demo.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/core/src/examples/input-select-layout-demo.ts b/packages/core/src/examples/input-select-layout-demo.ts index ce791df93..4284ec1eb 100644 --- a/packages/core/src/examples/input-select-layout-demo.ts +++ b/packages/core/src/examples/input-select-layout-demo.ts @@ -1,7 +1,6 @@ -import { CliRenderer, BoxRenderable, TextRenderable, createCliRenderer, type ParsedKey } from "../index" +import { CliRenderer, BoxRenderable, TextRenderable, createCliRenderer } from "../index" import { InputRenderable, InputRenderableEvents } from "../renderables/Input" import { SelectRenderable, SelectRenderableEvents, type SelectOption } from "../renderables/Select" -import { getKeyHandler } from "../lib/KeyHandler" import { setupCommonDemoKeys } from "./lib/standalone-keys" let renderer: CliRenderer | null = null @@ -20,7 +19,6 @@ let textInput: InputRenderable | null = null let textInputBox: BoxRenderable | null = null let footer: TextRenderable | null = null let footerBox: BoxRenderable | null = null -let currentFocusIndex = 0 const colorOptions: SelectOption[] = [ { name: "Red", description: "A warm primary color", value: "#ff0000" }, @@ -42,6 +40,8 @@ function createLayoutElements(rendererInstance: CliRenderer): void { renderer = rendererInstance renderer.setBackgroundColor("#001122") + renderer.start() + headerBox = new BoxRenderable(renderer, { id: "header-box", zIndex: 0, @@ -375,7 +375,6 @@ export function destroy(rendererInstance: CliRenderer): void { footer = null footerBox = null renderer = null - currentFocusIndex = 0 } if (import.meta.main) { From 1d5d71954e784894c49edd099ad7ec8da4185a14 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 13:59:41 +0200 Subject: [PATCH 07/26] Add focusKeyHandler config to createCliRenderer --- packages/core/src/lib/FocusManager.ts | 31 ++++++++++++++++++++++----- packages/core/src/renderer.ts | 5 +++-- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index 92d7769e2..9d31e1459 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -3,6 +3,12 @@ import type { Renderable } from "../Renderable" import { YGTreeWalker } from "./YGTreeWalker" import type { ParsedKey } from "./parse.keypress" +export type FocusKeyHandler = (key: ParsedKey, focusNext: () => void, focusPrev: () => void) => void + +interface FocusManagerConfig { + onKey?: FocusKeyHandler +} + export class FocusManager { private static instance: FocusManager | null = null @@ -10,10 +16,11 @@ export class FocusManager { private readonly root: Renderable private current: Renderable | null = null private walker: YGTreeWalker | null = null + private onKey?: FocusKeyHandler - static install(root: Renderable): FocusManager { + static install(root: Renderable, config?: FocusManagerConfig): FocusManager { if (this.instance) return this.instance - const mgr = new FocusManager(root) + const mgr = new FocusManager(root, config) this.instance = mgr mgr.attach() mgr.initFocus() @@ -25,9 +32,10 @@ export class FocusManager { this.instance = null } - constructor(root: Renderable) { + constructor(root: Renderable, config?: FocusManagerConfig) { this.root = root this.walker = new YGTreeWalker(this.root, (n) => this.isFocusable(n)) + this.onKey = config?.onKey } private getWalker(): YGTreeWalker { @@ -38,10 +46,23 @@ export class FocusManager { private attach(): void { const keyHandler = getKeyHandler() const keypress = (key: ParsedKey) => { - if (key.name === "tab") { - key.shift ? this.focusPrev() : this.focusNext() + if (this.onKey) { + this.onKey( + key, + () => this.focusNext(), + () => this.focusPrev(), + ) + } else { + if (key.name === "tab") { + key.shift ? this.focusPrev() : this.focusNext() + } else if (key.name === "up") { + this.focusPrev() + } else if (key.name === "down") { + this.focusNext() + } } } + keyHandler.on("keypress", keypress) this.keyUnsubscribe = () => keyHandler.off("keypress", keypress) } diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 276e0c856..f5e5ef0f2 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -16,7 +16,7 @@ import { MouseParser, type MouseEventType, type RawMouseEvent, type ScrollInfo } import { Selection } from "./lib/selection" import { EventEmitter } from "events" import { singleton } from "./singleton" -import { FocusManager } from "./lib/FocusManager" +import { FocusManager, type FocusKeyHandler } from "./lib/FocusManager" export interface CliRendererConfig { stdin?: NodeJS.ReadStream @@ -35,6 +35,7 @@ export interface CliRendererConfig { useAlternateScreen?: boolean useConsole?: boolean experimental_splitHeight?: number + focusKeyHandler?: FocusKeyHandler } export type PixelResolution = { @@ -125,7 +126,7 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise await renderer.setupTerminal() // Install default keyboard navigation (Tab/Shift+Tab) - FocusManager.install(renderer.root) + FocusManager.install(renderer.root, { onKey: config.focusKeyHandler }) return renderer } From b25d5507bc70fb8d37d482bfceb0cc2da0216ade Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 14:05:32 +0200 Subject: [PATCH 08/26] remove keyboard navigation on CliRenderer.destroy --- packages/core/src/renderer.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index f5e5ef0f2..271caffc9 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -125,7 +125,6 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise const renderer = new CliRenderer(ziglib, rendererPtr, stdin, stdout, width, height, config) await renderer.setupTerminal() - // Install default keyboard navigation (Tab/Shift+Tab) FocusManager.install(renderer.root, { onKey: config.focusKeyHandler }) return renderer @@ -194,11 +193,11 @@ export class CliRenderer extends EventEmitter implements RenderContext { renderTime?: number frameCallbackTime: number } = { - frameCount: 0, - fps: 0, - renderTime: 0, - frameCallbackTime: 0, - } + frameCount: 0, + fps: 0, + renderTime: 0, + frameCallbackTime: 0, + } public debugOverlay = { enabled: false, corner: DebugOverlayCorner.bottomRight, @@ -1044,6 +1043,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.sigwinchHandler = null } + FocusManager.uninstall() this._console.deactivate() this.disableStdoutInterception() this.lib.destroyRenderer(this.rendererPtr, this._useAlternateScreen, this._splitHeight) From dd44a03e46cca30e34872d98ab596b96d48fa7d2 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 15:33:08 +0200 Subject: [PATCH 09/26] Implement focusedRenderable in RenderContext --- packages/core/src/Renderable.ts | 3 ++ .../src/examples/input-select-layout-demo.ts | 2 + packages/core/src/lib/FocusManager.ts | 39 +++++++++++-------- packages/core/src/renderer.ts | 22 ++++++++--- packages/core/src/types.ts | 2 + 5 files changed, 45 insertions(+), 23 deletions(-) diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 81ae02f0c..f4ef7f973 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -330,6 +330,8 @@ export abstract class Renderable extends EventEmitter { public focus(): void { if (this._focused || !this.focusable) return + this.ctx.focusedRenderable?.blur() + this.ctx.focusedRenderable = this this._focused = true this.requestRender() @@ -347,6 +349,7 @@ export abstract class Renderable extends EventEmitter { public blur(): void { if (!this._focused || !this.focusable) return + this.ctx.focusedRenderable = null this._focused = false this.requestRender() diff --git a/packages/core/src/examples/input-select-layout-demo.ts b/packages/core/src/examples/input-select-layout-demo.ts index 4284ec1eb..4d9c5c197 100644 --- a/packages/core/src/examples/input-select-layout-demo.ts +++ b/packages/core/src/examples/input-select-layout-demo.ts @@ -279,6 +279,8 @@ function createLayoutElements(rendererInstance: CliRenderer): void { renderer.root.add(inputContainerBox) renderer.root.add(footerBox) + textInput.focus() + setupEventHandlers() renderer.on("resize", handleResize) diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index 9d31e1459..86762b352 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -2,6 +2,7 @@ import { getKeyHandler } from "./KeyHandler" import type { Renderable } from "../Renderable" import { YGTreeWalker } from "./YGTreeWalker" import type { ParsedKey } from "./parse.keypress" +import type { CliRenderer } from "../renderer" export type FocusKeyHandler = (key: ParsedKey, focusNext: () => void, focusPrev: () => void) => void @@ -13,14 +14,13 @@ export class FocusManager { private static instance: FocusManager | null = null private keyUnsubscribe: (() => void) | null = null - private readonly root: Renderable - private current: Renderable | null = null + private readonly renderer: CliRenderer private walker: YGTreeWalker | null = null private onKey?: FocusKeyHandler - static install(root: Renderable, config?: FocusManagerConfig): FocusManager { + static install(renderer: CliRenderer, config?: FocusManagerConfig): FocusManager { if (this.instance) return this.instance - const mgr = new FocusManager(root, config) + const mgr = new FocusManager(renderer, config) this.instance = mgr mgr.attach() mgr.initFocus() @@ -32,15 +32,20 @@ export class FocusManager { this.instance = null } - constructor(root: Renderable, config?: FocusManagerConfig) { - this.root = root - this.walker = new YGTreeWalker(this.root, (n) => this.isFocusable(n)) + constructor(renderer: CliRenderer, config?: FocusManagerConfig) { + this.renderer = renderer + this.walker = new YGTreeWalker(this.renderer.root, (n) => this.isFocusable(n)) this.onKey = config?.onKey } private getWalker(): YGTreeWalker { - if (this.current && this.walker) this.walker.currentNode = this.current - return this.walker! + if (!this.walker) throw new Error("Walker not initialized") + + if (this.renderer.focusedRenderable) { + this.walker.currentNode = this.renderer.focusedRenderable + } + + return this.walker } private attach(): void { @@ -70,7 +75,7 @@ export class FocusManager { private detach(): void { this.keyUnsubscribe?.() this.keyUnsubscribe = null - this.current = null + this.renderer.focusedRenderable = null this.walker = null } @@ -82,7 +87,7 @@ export class FocusManager { const walker = this.getWalker() const first = walker.firstAccepted() if (first) { - this.current = first + this.renderer.focusedRenderable = first first.focus() } } @@ -96,9 +101,9 @@ export class FocusManager { private focusNext() { const next = this.findNextFocusable() if (!next) return - this.current?.blur() - this.current = next - this.current.focus() + this.renderer.focusedRenderable?.blur() + this.renderer.focusedRenderable = next + this.renderer.focusedRenderable.focus() } private findPrevFocusable(): Renderable | null { @@ -110,8 +115,8 @@ export class FocusManager { private focusPrev() { const prev = this.findPrevFocusable() if (!prev) return - this.current?.blur() - this.current = prev - this.current.focus() + this.renderer.focusedRenderable?.blur() + this.renderer.focusedRenderable = prev + this.renderer.focusedRenderable.focus() } } diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 271caffc9..61e949b16 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -125,7 +125,7 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise const renderer = new CliRenderer(ziglib, rendererPtr, stdin, stdout, width, height, config) await renderer.setupTerminal() - FocusManager.install(renderer.root, { onKey: config.focusKeyHandler }) + FocusManager.install(renderer, { onKey: config.focusKeyHandler }) return renderer } @@ -193,11 +193,11 @@ export class CliRenderer extends EventEmitter implements RenderContext { renderTime?: number frameCallbackTime: number } = { - frameCount: 0, - fps: 0, - renderTime: 0, - frameCallbackTime: 0, - } + frameCount: 0, + fps: 0, + renderTime: 0, + frameCallbackTime: 0, + } public debugOverlay = { enabled: false, corner: DebugOverlayCorner.bottomRight, @@ -208,6 +208,8 @@ export class CliRenderer extends EventEmitter implements RenderContext { private animationRequest: Map = new Map() + private _focusedRenderable: Renderable | null = null + private resizeTimeoutId: ReturnType | null = null private resizeDebounceDelay: number = 100 @@ -363,6 +365,14 @@ export class CliRenderer extends EventEmitter implements RenderContext { } } + public set focusedRenderable(renderable: Renderable | null) { + this._focusedRenderable = renderable + } + + public get focusedRenderable(): Renderable | null { + return this._focusedRenderable + } + public addToHitGrid(x: number, y: number, width: number, height: number, id: number) { if (id !== this.capturedRenderable?.num) { this.lib.addToHitGrid(this.rendererPtr, x, y, width, height, id) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 6d1e967bd..04569fecf 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,4 +1,5 @@ import type { RGBA } from "./lib/RGBA" +import type { Renderable } from "./Renderable" export const TextAttributes = { NONE: 0, @@ -35,6 +36,7 @@ export interface RenderContext { capabilities: any | null requestLive: () => void dropLive: () => void + focusedRenderable: Renderable | null } export interface SelectionState { From 288f7e3a3e0229a45c39b06deb1c67dbc8907871 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 16:46:20 +0200 Subject: [PATCH 10/26] Refactor FocusManager to use globalEmitter for tree change events --- packages/core/src/lib/FocusManager.ts | 11 +++++++---- packages/core/src/lib/TrackedNode.ts | 13 +++++++------ packages/core/src/lib/YGTreeWalker.ts | 4 ---- packages/core/src/lib/globalEmitter.ts | 18 ++++++++++++++++++ 4 files changed, 32 insertions(+), 14 deletions(-) create mode 100644 packages/core/src/lib/globalEmitter.ts diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index 86762b352..bd6b813d3 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -3,6 +3,7 @@ import type { Renderable } from "../Renderable" import { YGTreeWalker } from "./YGTreeWalker" import type { ParsedKey } from "./parse.keypress" import type { CliRenderer } from "../renderer" +import { globalEmitter } from "./globalEmitter" export type FocusKeyHandler = (key: ParsedKey, focusNext: () => void, focusPrev: () => void) => void @@ -13,6 +14,8 @@ interface FocusManagerConfig { export class FocusManager { private static instance: FocusManager | null = null + private globalListener: () => void + private keyUnsubscribe: (() => void) | null = null private readonly renderer: CliRenderer private walker: YGTreeWalker | null = null @@ -36,6 +39,9 @@ export class FocusManager { this.renderer = renderer this.walker = new YGTreeWalker(this.renderer.root, (n) => this.isFocusable(n)) this.onKey = config?.onKey + + this.globalListener = () => this.walker?.reset() + globalEmitter.on("treeChanged", this.globalListener) } private getWalker(): YGTreeWalker { @@ -60,10 +66,6 @@ export class FocusManager { } else { if (key.name === "tab") { key.shift ? this.focusPrev() : this.focusNext() - } else if (key.name === "up") { - this.focusPrev() - } else if (key.name === "down") { - this.focusNext() } } } @@ -75,6 +77,7 @@ export class FocusManager { private detach(): void { this.keyUnsubscribe?.() this.keyUnsubscribe = null + globalEmitter.off("treeChanged", this.globalListener) this.renderer.focusedRenderable = null this.walker = null } diff --git a/packages/core/src/lib/TrackedNode.ts b/packages/core/src/lib/TrackedNode.ts index ee0dbeff2..cd1c4f9bf 100644 --- a/packages/core/src/lib/TrackedNode.ts +++ b/packages/core/src/lib/TrackedNode.ts @@ -1,5 +1,6 @@ import Yoga, { type Config, type Node as YogaNode } from "yoga-layout" import { EventEmitter } from "events" +import { globalEmitter } from "./globalEmitter" // TrackedNode // A TypeScript wrapper for Yoga nodes that tracks indices and maintains parent-child relationships. @@ -105,7 +106,7 @@ class TrackedNode extends EventEmitter { console.error("Error setting width and height", e) } - this.emit("treeChanged", this) + globalEmitter.emit("treeChanged", this) return index } @@ -124,7 +125,7 @@ class TrackedNode extends EventEmitter { childNode.parent = null - this.emit("treeChanged", this) + globalEmitter.emit("treeChanged", this) return true } @@ -140,7 +141,7 @@ class TrackedNode extends EventEmitter { childNode.parent = null - this.emit("treeChanged", this) + globalEmitter.emit("treeChanged", this) return childNode } @@ -162,7 +163,7 @@ class TrackedNode extends EventEmitter { this.yogaNode.removeChild(childNode.yogaNode) this.yogaNode.insertChild(childNode.yogaNode, boundedNewIndex) - this.emit("treeChanged", this) + globalEmitter.emit("treeChanged", this) return boundedNewIndex } @@ -184,7 +185,7 @@ class TrackedNode extends EventEmitter { console.error("Error setting width and height", e) } - this.emit("treeChanged", this) + globalEmitter.emit("treeChanged", this) return boundedIndex } @@ -227,7 +228,7 @@ class TrackedNode extends EventEmitter { } catch (e) { // Might be already freed and will throw an error if we try to free it again } - this.emit("treeChanged", this) + globalEmitter.emit("treeChanged", this) this._destroyed = true } } diff --git a/packages/core/src/lib/YGTreeWalker.ts b/packages/core/src/lib/YGTreeWalker.ts index 8fdf43721..1e9e32889 100644 --- a/packages/core/src/lib/YGTreeWalker.ts +++ b/packages/core/src/lib/YGTreeWalker.ts @@ -12,10 +12,6 @@ export class YGTreeWalker { this._current = root this.accept = accept this.rootNode = this.root.getLayoutNode() - - this.rootNode.on("treeChanged", () => { - this.reset() - }) } public reset() { diff --git a/packages/core/src/lib/globalEmitter.ts b/packages/core/src/lib/globalEmitter.ts new file mode 100644 index 000000000..12bd4bdda --- /dev/null +++ b/packages/core/src/lib/globalEmitter.ts @@ -0,0 +1,18 @@ +import { EventEmitter } from "events" +import type { TrackedNode } from "./TrackedNode" + +export type GlobalEvents = { + treeChanged: TrackedNode +} + +class TypedEmitter extends EventEmitter { + emit(event: K, payload: GlobalEvents[K]) { + return super.emit(event, payload) + } + + on(event: K, listener: (payload: GlobalEvents[K]) => void) { + return super.on(event, listener) + } +} + +export const globalEmitter = new TypedEmitter() From 46b5609c62e258d79ea3d029ff03196a5bd83f34 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 17:31:41 +0200 Subject: [PATCH 11/26] Add Nested Input Tree Demo --- packages/core/src/examples/index.ts | 7 + .../core/src/examples/nested-input-tree.ts | 130 ++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 packages/core/src/examples/nested-input-tree.ts diff --git a/packages/core/src/examples/index.ts b/packages/core/src/examples/index.ts index 784173951..bc5583017 100644 --- a/packages/core/src/examples/index.ts +++ b/packages/core/src/examples/index.ts @@ -46,6 +46,7 @@ import * as vnodeCompositionDemo from "./vnode-composition-demo" import * as hastSyntaxHighlightingExample from "./hast-syntax-highlighting-demo" import * as liveStateExample from "./live-state-demo" import * as fullUnicodeExample from "./full-unicode-demo" +import * as nestedInputTreeExample from "./nested-input-tree" import { getKeyHandler } from "../lib/KeyHandler" import { setupCommonDemoKeys } from "./lib/standalone-keys" @@ -111,6 +112,12 @@ const examples: Example[] = [ run: inputSelectLayoutExample.run, destroy: inputSelectLayoutExample.destroy, }, + { + name: "Nested Input Tree Demo", + description: "Demonstrates the Focus Manager with a recursive tree of nested labeled inputs and sublevels", + run: nestedInputTreeExample.run, + destroy: nestedInputTreeExample.destroy, + }, { name: "ASCII Font Demo", description: "ASCII font rendering with various colors and text", diff --git a/packages/core/src/examples/nested-input-tree.ts b/packages/core/src/examples/nested-input-tree.ts new file mode 100644 index 000000000..90c2469e8 --- /dev/null +++ b/packages/core/src/examples/nested-input-tree.ts @@ -0,0 +1,130 @@ +import { CliRenderer, createCliRenderer, BoxRenderable, InputRenderable, TextRenderable } from ".." +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +const MAX_DEPTH = 3 +const MIN_INPUTS = 1 +const MAX_INPUTS = 2 +const MIN_SUBLEVELS = 2 +const MAX_SUBLEVELS = 3 + +let renderer: CliRenderer | null = null +let parentContainer: BoxRenderable | null = null +let footer: TextRenderable | null = null +let footerBox: BoxRenderable | null = null + +const PLACEHOLDER_TEMPLATES = [ + "Enter your name", + "Type your email", + "Write a comment", + "Your favorite color", + "Add a note here", +] + +function getLevelColor(depth: number) { + const LEVEL_COLORS = ["#3b82f6", "#059669", "#f59e0b", "#e11d48"] + return LEVEL_COLORS[(depth - 1) % LEVEL_COLORS.length] +} + +function createNestedBox(depth: number, maxDepth: number): BoxRenderable { + if (!renderer) throw new Error("No renderer") + + const levelColor = getLevelColor(depth) + + const box = new BoxRenderable(renderer, { + zIndex: 0, + width: "auto", + height: "auto", + borderStyle: "single", + borderColor: levelColor, + focusedBorderColor: levelColor, + title: `Level ${depth}`, + titleAlignment: "center", + flexGrow: 1, + flexShrink: 1, + backgroundColor: "transparent", + border: true, + }) + + const inputCount = Math.floor(Math.random() * (MAX_INPUTS - MIN_INPUTS + 1)) + MIN_INPUTS + for (let i = 0; i < inputCount; i++) { + const placeholder = PLACEHOLDER_TEMPLATES[i % PLACEHOLDER_TEMPLATES.length] + box.add( + new InputRenderable(renderer, { + placeholder: `${placeholder} (level ${depth})`, + width: "auto", + height: 1, + backgroundColor: "#1e293b", + focusedBackgroundColor: "#334155", + textColor: levelColor, + focusedTextColor: "#ffffff", + placeholderColor: "#64748b", + cursorColor: levelColor, + maxLength: 100, + }), + ) + } + + if (depth < maxDepth) { + const sublevelCount = Math.floor(Math.random() * (MAX_SUBLEVELS - MIN_SUBLEVELS + 1)) + MIN_SUBLEVELS + for (let j = 0; j < sublevelCount; j++) { + box.add(createNestedBox(depth + 1, maxDepth)) + } + } + + return box +} + +export function run(rendererInstance: CliRenderer): void { + renderer = rendererInstance + renderer.setBackgroundColor("#001122") + + parentContainer = createNestedBox(1, MAX_DEPTH) + + renderer.root.add(parentContainer) + + footerBox = new BoxRenderable(renderer, { + width: "auto", + height: 3, + backgroundColor: "#1e40af", + borderStyle: "single", + borderColor: "#1d4ed8", + border: true, + }) + + footer = new TextRenderable(renderer, { + id: "footer", + content: "TAB: focus next | SHIFT+TAB: focus prev | ESC: quit", + fg: "#dbeafe", + bg: "transparent", + zIndex: 1, + flexGrow: 1, + flexShrink: 1, + }) + + footerBox.add(footer) + renderer.root.add(footerBox) +} + +export function destroy(rendererInstance: CliRenderer): void { + if (parentContainer) { + rendererInstance.root.remove(parentContainer.id) + parentContainer.destroyRecursively() + } + + if (footerBox) rendererInstance.root.remove(footerBox.id) + + parentContainer = null + footer = null + footerBox = null + renderer = null +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + exitOnCtrlC: true, + targetFps: 30, + }) + run(renderer) + setupCommonDemoKeys(renderer) + renderer.start() +} From 3aa46637d5e9254d39e8f23cfc3fb90119878747 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 17:34:31 +0200 Subject: [PATCH 12/26] fix --- packages/core/src/examples/input-select-layout-demo.ts | 2 -- packages/core/src/examples/nested-input-tree.ts | 1 - 2 files changed, 3 deletions(-) diff --git a/packages/core/src/examples/input-select-layout-demo.ts b/packages/core/src/examples/input-select-layout-demo.ts index 4d9c5c197..03441450b 100644 --- a/packages/core/src/examples/input-select-layout-demo.ts +++ b/packages/core/src/examples/input-select-layout-demo.ts @@ -40,8 +40,6 @@ function createLayoutElements(rendererInstance: CliRenderer): void { renderer = rendererInstance renderer.setBackgroundColor("#001122") - renderer.start() - headerBox = new BoxRenderable(renderer, { id: "header-box", zIndex: 0, diff --git a/packages/core/src/examples/nested-input-tree.ts b/packages/core/src/examples/nested-input-tree.ts index 90c2469e8..c6c5c7f67 100644 --- a/packages/core/src/examples/nested-input-tree.ts +++ b/packages/core/src/examples/nested-input-tree.ts @@ -40,7 +40,6 @@ function createNestedBox(depth: number, maxDepth: number): BoxRenderable { title: `Level ${depth}`, titleAlignment: "center", flexGrow: 1, - flexShrink: 1, backgroundColor: "transparent", border: true, }) From c5d795df23110fde4035f93ab8329364d5f788e6 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 18:17:05 +0200 Subject: [PATCH 13/26] randomizing input and sublevel distribution based on depth on nested-input-tree demo --- .../core/src/examples/nested-input-tree.ts | 50 +++++++++++-------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/packages/core/src/examples/nested-input-tree.ts b/packages/core/src/examples/nested-input-tree.ts index c6c5c7f67..f9c6fe5c0 100644 --- a/packages/core/src/examples/nested-input-tree.ts +++ b/packages/core/src/examples/nested-input-tree.ts @@ -44,31 +44,39 @@ function createNestedBox(depth: number, maxDepth: number): BoxRenderable { border: true, }) - const inputCount = Math.floor(Math.random() * (MAX_INPUTS - MIN_INPUTS + 1)) + MIN_INPUTS - for (let i = 0; i < inputCount; i++) { - const placeholder = PLACEHOLDER_TEMPLATES[i % PLACEHOLDER_TEMPLATES.length] - box.add( - new InputRenderable(renderer, { - placeholder: `${placeholder} (level ${depth})`, - width: "auto", - height: 1, - backgroundColor: "#1e293b", - focusedBackgroundColor: "#334155", - textColor: levelColor, - focusedTextColor: "#ffffff", - placeholderColor: "#64748b", - cursorColor: levelColor, - maxLength: 100, - }), - ) + const inputCount = depth === 1 ? MAX_INPUTS : Math.floor(Math.random() * (MAX_INPUTS - MIN_INPUTS + 1)) + MIN_INPUTS + const sublevelCount = + depth < maxDepth ? Math.floor(Math.random() * (MAX_SUBLEVELS - MIN_SUBLEVELS + 1)) + MIN_SUBLEVELS : 0 + + const elements = Array(inputCount).fill("input").concat(Array(sublevelCount).fill("sublevel")) + + for (let i = elements.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[elements[i], elements[j]] = [elements[j], elements[i]] } - if (depth < maxDepth) { - const sublevelCount = Math.floor(Math.random() * (MAX_SUBLEVELS - MIN_SUBLEVELS + 1)) + MIN_SUBLEVELS - for (let j = 0; j < sublevelCount; j++) { + elements.forEach((type, idx) => { + if (type === "input") { + if (!renderer) throw new Error("No renderer") + const placeholder = PLACEHOLDER_TEMPLATES[idx % PLACEHOLDER_TEMPLATES.length] + box.add( + new InputRenderable(renderer, { + placeholder: `${placeholder} (level ${depth})`, + width: "auto", + height: 1, + backgroundColor: "#1e293b", + focusedBackgroundColor: "#334155", + textColor: levelColor, + focusedTextColor: "#ffffff", + placeholderColor: "#64748b", + cursorColor: levelColor, + maxLength: 100, + }), + ) + } else if (type === "sublevel") { box.add(createNestedBox(depth + 1, maxDepth)) } - } + }) return box } From 2285ab1ef93ea8dc3eb84bd4f69a410b3fc286b6 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 19:19:39 +0200 Subject: [PATCH 14/26] Remove keyboard navigation handler from input-demo --- packages/core/src/examples/input-demo.ts | 46 ------------------------ 1 file changed, 46 deletions(-) diff --git a/packages/core/src/examples/input-demo.ts b/packages/core/src/examples/input-demo.ts index b519133f9..f80530f5b 100644 --- a/packages/core/src/examples/input-demo.ts +++ b/packages/core/src/examples/input-demo.ts @@ -11,14 +11,12 @@ import { } from "../index" import { setupCommonDemoKeys } from "./lib/standalone-keys" import { TextRenderable } from "../renderables/Text" -import { getKeyHandler } from "../lib/KeyHandler" let nameInput: InputRenderable | null = null let emailInput: InputRenderable | null = null let passwordInput: InputRenderable | null = null let commentInput: InputRenderable | null = null let renderer: CliRenderer | null = null -let keyboardHandler: ((key: any) => void) | null = null let keyLegendDisplay: TextRenderable | null = null let statusDisplay: TextRenderable | null = null let lastActionText: string = "Welcome to InputRenderable demo! Use Tab to navigate between fields." @@ -299,54 +297,10 @@ export function run(rendererInstance: CliRenderer): void { updateDisplays() - keyboardHandler = (key) => { - const anyInputFocused = inputElements.some((input) => input.focused) - - if (key.name === "tab") { - if (key.shift) { - // Navigate backward - navigateToInput(activeInputIndex - 1) - } else { - // Navigate forward - navigateToInput(activeInputIndex + 1) - } - } else if (key.ctrl && key.name === "f") { - // Only respond to Ctrl+F for focus toggle - const activeInput = getActiveInput() - if (activeInput?.focused) { - activeInput.blur() - lastActionText = `Focus removed from ${getInputName(activeInput)} input` - } else { - activeInput?.focus() - lastActionText = `${getInputName(activeInput)} input focused` - } - lastActionColor = "#FFCC00" - updateDisplays() - } else if (key.ctrl && key.name === "c") { - // Only respond to Ctrl+C for clear - const activeInput = getActiveInput() - if (activeInput) { - activeInput.value = "" - lastActionText = `${getInputName(activeInput)} input cleared` - lastActionColor = "#FFAA00" - updateDisplays() - } - } else if (key.ctrl && key.name === "r") { - // Only respond to Ctrl+R for reset - resetInputs() - } - } - - getKeyHandler().on("keypress", keyboardHandler) nameInput.focus() } export function destroy(rendererInstance: CliRenderer): void { - if (keyboardHandler) { - getKeyHandler().off("keypress", keyboardHandler) - keyboardHandler = null - } - inputElements.forEach((input) => { if (input) { rendererInstance.root.remove(input.id) From deca42f4805b419ccf6bcf5542c66bdb79f35048 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 19:22:34 +0200 Subject: [PATCH 15/26] Add enableFocusManager option to CliRendererConfig --- packages/core/src/renderer.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 61e949b16..d42e27cdf 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -36,6 +36,7 @@ export interface CliRendererConfig { useConsole?: boolean experimental_splitHeight?: number focusKeyHandler?: FocusKeyHandler + enableFocusManager?: boolean } export type PixelResolution = { @@ -125,7 +126,9 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise const renderer = new CliRenderer(ziglib, rendererPtr, stdin, stdout, width, height, config) await renderer.setupTerminal() - FocusManager.install(renderer, { onKey: config.focusKeyHandler }) + if (config.enableFocusManager ?? true) { + FocusManager.install(renderer, { onKey: config.focusKeyHandler }) + } return renderer } From ead817ae16efd6a2314d11209212f11c15d798ad Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 19:41:38 +0200 Subject: [PATCH 16/26] rename enableFocusManager to useFocusManager in CliRendererConfig --- packages/core/src/renderer.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index d42e27cdf..060ce3319 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -36,7 +36,7 @@ export interface CliRendererConfig { useConsole?: boolean experimental_splitHeight?: number focusKeyHandler?: FocusKeyHandler - enableFocusManager?: boolean + useFocusManager?: boolean } export type PixelResolution = { @@ -126,7 +126,7 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise const renderer = new CliRenderer(ziglib, rendererPtr, stdin, stdout, width, height, config) await renderer.setupTerminal() - if (config.enableFocusManager ?? true) { + if (config.useFocusManager ?? true) { FocusManager.install(renderer, { onKey: config.focusKeyHandler }) } @@ -196,11 +196,11 @@ export class CliRenderer extends EventEmitter implements RenderContext { renderTime?: number frameCallbackTime: number } = { - frameCount: 0, - fps: 0, - renderTime: 0, - frameCallbackTime: 0, - } + frameCount: 0, + fps: 0, + renderTime: 0, + frameCallbackTime: 0, + } public debugOverlay = { enabled: false, corner: DebugOverlayCorner.bottomRight, From 47d555f542ac0fdf5d1cb12c5c118496f96982dc Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 4 Sep 2025 19:50:39 +0200 Subject: [PATCH 17/26] run prettier --- packages/core/src/renderer.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 060ce3319..a05f2944a 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -196,11 +196,11 @@ export class CliRenderer extends EventEmitter implements RenderContext { renderTime?: number frameCallbackTime: number } = { - frameCount: 0, - fps: 0, - renderTime: 0, - frameCallbackTime: 0, - } + frameCount: 0, + fps: 0, + renderTime: 0, + frameCallbackTime: 0, + } public debugOverlay = { enabled: false, corner: DebugOverlayCorner.bottomRight, From 85c69c897f935701f65dd64e54d815109f4acf61 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Tue, 9 Sep 2025 21:28:48 +0200 Subject: [PATCH 18/26] Remove TreeWalker --- packages/core/src/Renderable.ts | 8 ++ packages/core/src/lib/FocusManager.ts | 65 ++++++----- packages/core/src/lib/TrackedNode.ts | 7 -- packages/core/src/lib/YGTreeWalker.ts | 144 ------------------------- packages/core/src/lib/globalEmitter.ts | 18 ---- packages/core/src/renderer.ts | 38 +++++++ packages/core/src/types.ts | 3 + 7 files changed, 80 insertions(+), 203 deletions(-) delete mode 100644 packages/core/src/lib/YGTreeWalker.ts delete mode 100644 packages/core/src/lib/globalEmitter.ts diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 45161fea4..59ee2a49f 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -218,6 +218,7 @@ export abstract class Renderable extends EventEmitter { private _dirty: boolean = false protected focusable: boolean = false + protected tabbable: boolean = false protected _focused: boolean = false protected keyHandler: KeyHandler = getKeyHandler() protected keypressHandler: ((key: ParsedKey) => void) | null = null @@ -1103,6 +1104,10 @@ export abstract class Renderable extends EventEmitter { this.propagateLiveCount(obj._liveCount) } + if (obj.focusable) { + this.ctx.addFocusable(obj) + } + this.requestRender() return insertedIndex @@ -1145,6 +1150,9 @@ export abstract class Renderable extends EventEmitter { const childLayoutNode = obj.getLayoutNode() this.layoutNode.removeChild(childLayoutNode) + if (obj.focusable) { + this.ctx.removeFocusable(obj) + } this.requestRender() obj.onRemove() diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index bd6b813d3..b2f53d12d 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -1,9 +1,7 @@ import { getKeyHandler } from "./KeyHandler" import type { Renderable } from "../Renderable" -import { YGTreeWalker } from "./YGTreeWalker" import type { ParsedKey } from "./parse.keypress" import type { CliRenderer } from "../renderer" -import { globalEmitter } from "./globalEmitter" export type FocusKeyHandler = (key: ParsedKey, focusNext: () => void, focusPrev: () => void) => void @@ -14,11 +12,8 @@ interface FocusManagerConfig { export class FocusManager { private static instance: FocusManager | null = null - private globalListener: () => void - private keyUnsubscribe: (() => void) | null = null private readonly renderer: CliRenderer - private walker: YGTreeWalker | null = null private onKey?: FocusKeyHandler static install(renderer: CliRenderer, config?: FocusManagerConfig): FocusManager { @@ -37,21 +32,20 @@ export class FocusManager { constructor(renderer: CliRenderer, config?: FocusManagerConfig) { this.renderer = renderer - this.walker = new YGTreeWalker(this.renderer.root, (n) => this.isFocusable(n)) this.onKey = config?.onKey - - this.globalListener = () => this.walker?.reset() - globalEmitter.on("treeChanged", this.globalListener) } - private getWalker(): YGTreeWalker { - if (!this.walker) throw new Error("Walker not initialized") + private getFocusables(): Renderable[] { + console.log( + "Focusables:", + this.renderer.focusables.map((callback) => callback.id), + ) - if (this.renderer.focusedRenderable) { - this.walker.currentNode = this.renderer.focusedRenderable - } + return this.renderer.focusables + } - return this.walker + private isVisible(r: Renderable): boolean { + return r["visible"] === true } private attach(): void { @@ -77,18 +71,11 @@ export class FocusManager { private detach(): void { this.keyUnsubscribe?.() this.keyUnsubscribe = null - globalEmitter.off("treeChanged", this.globalListener) this.renderer.focusedRenderable = null - this.walker = null - } - - private isFocusable(r: Renderable): boolean { - return r["focusable"] === true && r["_visible"] === true } - private initFocus() { - const walker = this.getWalker() - const first = walker.firstAccepted() + private initFocus(): void { + const first = this.getFocusables().find((r) => this.isVisible(r)) if (first) { this.renderer.focusedRenderable = first first.focus() @@ -96,30 +83,40 @@ export class FocusManager { } private findNextFocusable(): Renderable | null { - const walker = this.getWalker() - const next = walker.nextAccepted() - return next ?? walker.firstAccepted() + const focusables = this.getFocusables() + if (!this.renderer.focusedRenderable) return focusables.find((r) => this.isVisible(r)) ?? null + + const startIndex = focusables.indexOf(this.renderer.focusedRenderable) + 1 + for (let i = startIndex; i < focusables.length; i++) { + if (this.isVisible(focusables[i])) return focusables[i] + } + return focusables.find((r) => this.isVisible(r)) ?? null } - private focusNext() { + private focusNext(): void { const next = this.findNextFocusable() if (!next) return this.renderer.focusedRenderable?.blur() this.renderer.focusedRenderable = next - this.renderer.focusedRenderable.focus() + next.focus() } private findPrevFocusable(): Renderable | null { - const walker = this.getWalker() - const prev = walker.prevAccepted() - return prev ?? walker.lastAccepted() + const focusables = this.getFocusables() + if (!this.renderer.focusedRenderable) return [...focusables].reverse().find((r) => this.isVisible(r)) ?? null + + const startIndex = focusables.indexOf(this.renderer.focusedRenderable) - 1 + for (let i = startIndex; i >= 0; i--) { + if (this.isVisible(focusables[i])) return focusables[i] + } + return [...focusables].reverse().find((r) => this.isVisible(r)) ?? null } - private focusPrev() { + private focusPrev(): void { const prev = this.findPrevFocusable() if (!prev) return this.renderer.focusedRenderable?.blur() this.renderer.focusedRenderable = prev - this.renderer.focusedRenderable.focus() + prev.focus() } } diff --git a/packages/core/src/lib/TrackedNode.ts b/packages/core/src/lib/TrackedNode.ts index cd1c4f9bf..2782f6ccf 100644 --- a/packages/core/src/lib/TrackedNode.ts +++ b/packages/core/src/lib/TrackedNode.ts @@ -1,6 +1,5 @@ import Yoga, { type Config, type Node as YogaNode } from "yoga-layout" import { EventEmitter } from "events" -import { globalEmitter } from "./globalEmitter" // TrackedNode // A TypeScript wrapper for Yoga nodes that tracks indices and maintains parent-child relationships. @@ -106,7 +105,6 @@ class TrackedNode extends EventEmitter { console.error("Error setting width and height", e) } - globalEmitter.emit("treeChanged", this) return index } @@ -125,7 +123,6 @@ class TrackedNode extends EventEmitter { childNode.parent = null - globalEmitter.emit("treeChanged", this) return true } @@ -141,7 +138,6 @@ class TrackedNode extends EventEmitter { childNode.parent = null - globalEmitter.emit("treeChanged", this) return childNode } @@ -163,7 +159,6 @@ class TrackedNode extends EventEmitter { this.yogaNode.removeChild(childNode.yogaNode) this.yogaNode.insertChild(childNode.yogaNode, boundedNewIndex) - globalEmitter.emit("treeChanged", this) return boundedNewIndex } @@ -185,7 +180,6 @@ class TrackedNode extends EventEmitter { console.error("Error setting width and height", e) } - globalEmitter.emit("treeChanged", this) return boundedIndex } @@ -228,7 +222,6 @@ class TrackedNode extends EventEmitter { } catch (e) { // Might be already freed and will throw an error if we try to free it again } - globalEmitter.emit("treeChanged", this) this._destroyed = true } } diff --git a/packages/core/src/lib/YGTreeWalker.ts b/packages/core/src/lib/YGTreeWalker.ts deleted file mode 100644 index 1e9e32889..000000000 --- a/packages/core/src/lib/YGTreeWalker.ts +++ /dev/null @@ -1,144 +0,0 @@ -import type { Renderable } from "../Renderable" -import type { TrackedNode } from "./TrackedNode" - -export class YGTreeWalker { - public readonly root: Renderable - public readonly rootNode: TrackedNode - private _current: Renderable - private readonly accept?: (node: Renderable) => boolean - - constructor(root: Renderable, accept?: (node: Renderable) => boolean) { - this.root = root - this._current = root - this.accept = accept - this.rootNode = this.root.getLayoutNode() - } - - public reset() { - this._current = this.root - } - - public get currentNode(): Renderable { - return this._current - } - - public set currentNode(node: Renderable) { - this._current = node - } - - private isAccepted(node: Renderable): boolean { - return this.accept ? this.accept(node) : true - } - - private getParent(node: Renderable): Renderable | null { - return node.parent || null - } - - private getChildAt(node: Renderable, index: number): Renderable | null { - const children = node.getChildren() - return children[index] ?? null - } - - private getFirstChild(node: Renderable): Renderable | null { - return this.getChildAt(node, 0) - } - - private getLastChild(node: Renderable): Renderable | null { - const children = node.getChildren() - return children.length > 0 ? children[children.length - 1] : null - } - - private getNextSibling(node: Renderable): Renderable | null { - const parent = this.getParent(node) - if (!parent) return null - const siblings = parent.getChildren() - const idx = siblings.indexOf(node) - return idx >= 0 && idx + 1 < siblings.length ? siblings[idx + 1] : null - } - - private getPrevSibling(node: Renderable): Renderable | null { - const parent = this.getParent(node) - if (!parent) return null - const siblings = parent.getChildren() - const idx = siblings.indexOf(node) - return idx > 0 ? siblings[idx - 1] : null - } - - private nextRaw(from: Renderable): Renderable | null { - const child = this.getFirstChild(from) - if (child) return child - let node: Renderable | null = from - while (node) { - const sibling = this.getNextSibling(node) - if (sibling) return sibling - node = this.getParent(node) - } - return null - } - - private prevRaw(from: Renderable): Renderable | null { - const prevSibling = this.getPrevSibling(from) - if (prevSibling) { - let deepest: Renderable = prevSibling - while (true) { - const child = this.getLastChild(deepest) - if (!child) break - deepest = child - } - return deepest - } - return this.getParent(from) - } - - private *traverseForward(from: Renderable): Generator { - let node: Renderable | null = from - while ((node = this.nextRaw(node))) { - yield node - } - } - - private *traverseBackward(from: Renderable): Generator { - let node: Renderable | null = from - while ((node = this.prevRaw(node))) { - yield node - } - } - - public firstAccepted(): Renderable | null { - const stack: Renderable[] = [this.root] - while (stack.length > 0) { - const node = stack.pop()! - if (this.isAccepted(node)) return node - stack.push(...node.getChildren().reverse()) - } - return null - } - - public lastAccepted(): Renderable | null { - let node: Renderable | null = this.root - while (true) { - const lastChild: Renderable | null = node ? this.getLastChild(node) : null - if (!lastChild) break - node = lastChild - } - while (node) { - if (this.isAccepted(node)) return node - node = this.prevRaw(node) - } - return null - } - - public nextAccepted(): Renderable | null { - for (const node of this.traverseForward(this._current)) { - if (this.isAccepted(node)) return node - } - return null - } - - public prevAccepted(): Renderable | null { - for (const node of this.traverseBackward(this._current)) { - if (this.isAccepted(node)) return node - } - return null - } -} diff --git a/packages/core/src/lib/globalEmitter.ts b/packages/core/src/lib/globalEmitter.ts deleted file mode 100644 index 12bd4bdda..000000000 --- a/packages/core/src/lib/globalEmitter.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { EventEmitter } from "events" -import type { TrackedNode } from "./TrackedNode" - -export type GlobalEvents = { - treeChanged: TrackedNode -} - -class TypedEmitter extends EventEmitter { - emit(event: K, payload: GlobalEvents[K]) { - return super.emit(event, payload) - } - - on(event: K, listener: (payload: GlobalEvents[K]) => void) { - return super.on(event, listener) - } -} - -export const globalEmitter = new TypedEmitter() diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index 5c3c562a9..a995bdaae 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -214,6 +214,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { private animationRequest: Map = new Map() private _focusedRenderable: Renderable | null = null + private _focusables: Renderable[] = [] private resizeTimeoutId: ReturnType | null = null private resizeDebounceDelay: number = 100 @@ -373,6 +374,43 @@ export class CliRenderer extends EventEmitter implements RenderContext { } } + private *findParentInList(node: Renderable, list: Renderable[]): Generator { + let current = node.parent + while (current) { + if (list.includes(current)) { + yield current + return + } + current = current.parent + } + } + + public addFocusable(node: Renderable): void { + if (this._focusables.includes(node)) return + + let index = 0 + + const parentGen = this.findParentInList(node, this._focusables) + const parentFound = parentGen.next() + if (!parentFound.done) { + const parent = parentFound.value + index = this._focusables.indexOf(parent) + 1 + } else { + index = this._focusables.length + } + + this._focusables.splice(index, 0, node) + } + + public removeFocusable(node: Renderable): void { + const i = this._focusables.indexOf(node) + if (i !== -1) this._focusables.splice(i, 1) + } + + public get focusables(): Renderable[] { + return this._focusables + } + public set focusedRenderable(renderable: Renderable | null) { this._focusedRenderable = renderable } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 10182f01f..aa214f74e 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -50,6 +50,9 @@ export interface RenderContext extends EventEmitter { hasSelection: boolean getSelection: () => Selection | null requestSelectionUpdate: () => void + focusables: Renderable[] + removeFocusable: (renderable: Renderable) => void + addFocusable: (node: Renderable) => void } export type Timeout = ReturnType | undefined From fbbc2d1cf76adf9a269bc887008ac8e918b6c889 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Tue, 9 Sep 2025 21:53:12 +0200 Subject: [PATCH 19/26] fix --- packages/core/src/Renderable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 101e7aa7f..faf58c1e0 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -1119,7 +1119,7 @@ export abstract class Renderable extends EventEmitter { this.propagateLiveCount(renderable._liveCount) } - if (obj.focusable) { + if (isRenderable(obj) && obj.focusable) { this.ctx.addFocusable(obj) } From eff42b3a36ef0ed1a1e762eec3a2f3d57d8f8688 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Wed, 10 Sep 2025 16:11:33 +0200 Subject: [PATCH 20/26] remove generator --- packages/core/src/lib/FocusManager.ts | 5 ----- packages/core/src/renderer.ts | 12 +++++------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index b2f53d12d..2ccbc7a79 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -36,11 +36,6 @@ export class FocusManager { } private getFocusables(): Renderable[] { - console.log( - "Focusables:", - this.renderer.focusables.map((callback) => callback.id), - ) - return this.renderer.focusables } diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index a840ce931..f6897cb9f 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -388,15 +388,15 @@ export class CliRenderer extends EventEmitter implements RenderContext { } } - private *findParentInList(node: Renderable, list: Renderable[]): Generator { + private findParentInList(node: Renderable, list: Renderable[]): Renderable | undefined { let current = node.parent while (current) { if (list.includes(current)) { - yield current - return + return current } current = current.parent } + return undefined } public addFocusable(node: Renderable): void { @@ -404,10 +404,8 @@ export class CliRenderer extends EventEmitter implements RenderContext { let index = 0 - const parentGen = this.findParentInList(node, this._focusables) - const parentFound = parentGen.next() - if (!parentFound.done) { - const parent = parentFound.value + const parent = this.findParentInList(node, this._focusables) + if (parent) { index = this._focusables.indexOf(parent) + 1 } else { index = this._focusables.length From 81615c40a96febb908063b677e86c52a01cdf382 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Wed, 10 Sep 2025 16:24:21 +0200 Subject: [PATCH 21/26] fix --- packages/core/src/Renderable.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 3b6c78632..c97535683 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -250,7 +250,6 @@ export abstract class Renderable extends BaseRenderable { protected frameBuffer: OptimizedBuffer | null = null protected focusable: boolean = false - protected tabbable: boolean = false protected _focused: boolean = false protected keyHandler: KeyHandler = getKeyHandler() protected keypressHandler: ((key: ParsedKey) => void) | null = null From 3c81652f5abef79da464ea455f80a6e6eb12d9be Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Wed, 10 Sep 2025 16:37:10 +0200 Subject: [PATCH 22/26] fix --- packages/core/src/lib/FocusManager.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index 2ccbc7a79..50dff40d1 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -39,8 +39,8 @@ export class FocusManager { return this.renderer.focusables } - private isVisible(r: Renderable): boolean { - return r["visible"] === true + private isFocusable(r: Renderable): boolean { + return r["visible"] === true && r["focusable"] === true } private attach(): void { @@ -70,7 +70,7 @@ export class FocusManager { } private initFocus(): void { - const first = this.getFocusables().find((r) => this.isVisible(r)) + const first = this.getFocusables().find((r) => this.isFocusable(r)) if (first) { this.renderer.focusedRenderable = first first.focus() @@ -79,13 +79,13 @@ export class FocusManager { private findNextFocusable(): Renderable | null { const focusables = this.getFocusables() - if (!this.renderer.focusedRenderable) return focusables.find((r) => this.isVisible(r)) ?? null + if (!this.renderer.focusedRenderable) return focusables.find((r) => this.isFocusable(r)) ?? null const startIndex = focusables.indexOf(this.renderer.focusedRenderable) + 1 for (let i = startIndex; i < focusables.length; i++) { - if (this.isVisible(focusables[i])) return focusables[i] + if (this.isFocusable(focusables[i])) return focusables[i] } - return focusables.find((r) => this.isVisible(r)) ?? null + return focusables.find((r) => this.isFocusable(r)) ?? null } private focusNext(): void { @@ -98,13 +98,13 @@ export class FocusManager { private findPrevFocusable(): Renderable | null { const focusables = this.getFocusables() - if (!this.renderer.focusedRenderable) return [...focusables].reverse().find((r) => this.isVisible(r)) ?? null + if (!this.renderer.focusedRenderable) return focusables.findLast((r) => this.isFocusable(r)) ?? null const startIndex = focusables.indexOf(this.renderer.focusedRenderable) - 1 for (let i = startIndex; i >= 0; i--) { - if (this.isVisible(focusables[i])) return focusables[i] + if (this.isFocusable(focusables[i])) return focusables[i] } - return [...focusables].reverse().find((r) => this.isVisible(r)) ?? null + return focusables.findLast((r) => this.isFocusable(r)) ?? null } private focusPrev(): void { From c8920add8835933ca239febbdf4bf24333b1b132 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 11 Sep 2025 20:29:10 +0200 Subject: [PATCH 23/26] fix --- packages/core/src/Renderable.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index c97535683..e2408dd68 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -1199,9 +1199,7 @@ export abstract class Renderable extends BaseRenderable { const childLayoutNode = obj.getLayoutNode() this.layoutNode.removeChild(childLayoutNode) - if (obj.focusable) { this.ctx.removeFocusable(obj) - } this.requestRender() obj.onRemove() From 51cbcf3baaa89eac3f6e2eb305966c89abe74799 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Thu, 11 Sep 2025 20:33:56 +0200 Subject: [PATCH 24/26] run prettier --- packages/core/src/Renderable.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 3f9681683..738711208 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -1125,7 +1125,7 @@ export abstract class Renderable extends BaseRenderable { const childLayoutNode = obj.getLayoutNode() this.layoutNode.removeChild(childLayoutNode) - this.ctx.removeFocusable(obj) + this.ctx.removeFocusable(obj) this.requestRender() obj.onRemove() From 856c404099d9281058489ca1e2a39473a330a225 Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Fri, 19 Sep 2025 10:40:01 +0200 Subject: [PATCH 25/26] fix --- packages/core/src/Renderable.ts | 1 + packages/core/src/examples/lib/standalone-keys.ts | 4 ++++ packages/core/src/lib/FocusManager.ts | 1 + packages/core/src/renderer.ts | 10 +++++++++- 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index fdf0090f5..1e185afd3 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -1116,6 +1116,7 @@ export abstract class Renderable extends BaseRenderable { if (this.renderableMapById.has(id)) { const obj = this.renderableMapById.get(id) + if (obj) { if (obj._liveCount > 0) { this.propagateLiveCount(-obj._liveCount) diff --git a/packages/core/src/examples/lib/standalone-keys.ts b/packages/core/src/examples/lib/standalone-keys.ts index 035a47df8..51b95a85e 100644 --- a/packages/core/src/examples/lib/standalone-keys.ts +++ b/packages/core/src/examples/lib/standalone-keys.ts @@ -5,6 +5,10 @@ export function setupCommonDemoKeys(renderer: CliRenderer) { getKeyHandler().on("keypress", (key: ParsedKey) => { if (key.name === "`" || key.name === '"') { renderer.console.toggle() + } else if (key.name === "f") { + renderer.console.focus() + } else if (key.name === "b") { + renderer.console.blur() } else if (key.name === ".") { renderer.toggleDebugOverlay() } else if (key.name === "g" && key.ctrl) { diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts index 50dff40d1..a362b9bfd 100644 --- a/packages/core/src/lib/FocusManager.ts +++ b/packages/core/src/lib/FocusManager.ts @@ -67,6 +67,7 @@ export class FocusManager { this.keyUnsubscribe?.() this.keyUnsubscribe = null this.renderer.focusedRenderable = null + this.renderer.focusables = [] } private initFocus(): void { diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index d2e26bcac..b8de72d26 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -441,7 +441,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { } public removeFocusable(node: Renderable): void { - const i = this._focusables.indexOf(node) + const i = this._focusables.findIndex((f) => f.id === node.id) if (i !== -1) this._focusables.splice(i, 1) } @@ -449,6 +449,14 @@ export class CliRenderer extends EventEmitter implements RenderContext { return this._focusables } + public set focusables(node: Renderable | Renderable[]) { + if (Array.isArray(node)) { + this._focusables = node + } else { + this._focusables = [node] + } + } + public set focusedRenderable(renderable: Renderable | null) { this._focusedRenderable = renderable } From 0128e5a751dea770805850c715ee9774b850bd0e Mon Sep 17 00:00:00 2001 From: reversegem_7 Date: Fri, 19 Sep 2025 10:55:22 +0200 Subject: [PATCH 26/26] fix --- packages/core/src/examples/nested-input-tree.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/src/examples/nested-input-tree.ts b/packages/core/src/examples/nested-input-tree.ts index f9c6fe5c0..cde1125a7 100644 --- a/packages/core/src/examples/nested-input-tree.ts +++ b/packages/core/src/examples/nested-input-tree.ts @@ -114,11 +114,10 @@ export function run(rendererInstance: CliRenderer): void { export function destroy(rendererInstance: CliRenderer): void { if (parentContainer) { - rendererInstance.root.remove(parentContainer.id) parentContainer.destroyRecursively() } - if (footerBox) rendererInstance.root.remove(footerBox.id) + if (footerBox) footerBox.destroyRecursively() parentContainer = null footer = null