diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index 06a78194f..c82da13de 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -346,7 +346,8 @@ export abstract class Renderable extends BaseRenderable { public focus(): void { if (this._focused || !this._focusable) return - this._ctx.focusRenderable(this) + this.ctx.focusedRenderable?.blur() + this.ctx.focusedRenderable = this this._focused = true this.requestRender() @@ -364,6 +365,7 @@ export abstract class Renderable extends BaseRenderable { public blur(): void { if (!this._focused || !this._focusable) return + this.ctx.focusedRenderable = null this._focused = false this.requestRender() @@ -1046,6 +1048,10 @@ export abstract class Renderable extends BaseRenderable { this.propagateLiveCount(renderable._liveCount) } + if (isRenderable(obj) && obj.focusable) { + this.ctx.addFocusable(obj) + } + this.requestRender() return insertedIndex @@ -1130,12 +1136,14 @@ 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) } const childLayoutNode = obj.getLayoutNode() + this.ctx.removeFocusable(obj) this.yogaNode.removeChild(childLayoutNode) this.requestRender() diff --git a/packages/core/src/examples/index.ts b/packages/core/src/examples/index.ts index 51f0eec05..6bc770b1e 100644 --- a/packages/core/src/examples/index.ts +++ b/packages/core/src/examples/index.ts @@ -47,6 +47,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 * as textNodeDemo from "./text-node-demo" import { getKeyHandler } from "../lib/KeyHandler" import { setupCommonDemoKeys } from "./lib/standalone-keys" @@ -119,6 +120,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", @@ -419,7 +426,6 @@ class ExampleSelector { case "\u0003": this.cleanup() process.exit() - break } switch (key.name) { case "c": @@ -461,6 +467,7 @@ class ExampleSelector { this.selectBox.visible = false } if (this.selectElement) { + this.selectElement.visible = false this.selectElement.blur() } } @@ -472,6 +479,7 @@ class ExampleSelector { this.selectBox.visible = true } if (this.selectElement) { + this.selectElement.visible = true this.selectElement.focus() } } 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) diff --git a/packages/core/src/examples/input-select-layout-demo.ts b/packages/core/src/examples/input-select-layout-demo.ts index e3e10f1a8..03441450b 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,10 +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 focusableElements: Array = [] -const focusableBoxes: Array = [] const colorOptions: SelectOption[] = [ { name: "Red", description: "A warm primary color", value: "#ff0000" }, @@ -282,10 +277,9 @@ function createLayoutElements(rendererInstance: CliRenderer): void { renderer.root.add(inputContainerBox) renderer.root.add(footerBox) - focusableElements.push(leftSelect, rightSelect, textInput) - focusableBoxes.push(leftSelectBox, rightSelectBox, textInputBox) + textInput.focus() + 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) } @@ -410,9 +375,6 @@ export function destroy(rendererInstance: CliRenderer): void { footer = null footerBox = null renderer = null - currentFocusIndex = 0 - focusableElements.length = 0 - focusableBoxes.length = 0 } if (import.meta.main) { 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/examples/nested-input-tree.ts b/packages/core/src/examples/nested-input-tree.ts new file mode 100644 index 000000000..cde1125a7 --- /dev/null +++ b/packages/core/src/examples/nested-input-tree.ts @@ -0,0 +1,136 @@ +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, + backgroundColor: "transparent", + border: true, + }) + + 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]] + } + + 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 +} + +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) { + parentContainer.destroyRecursively() + } + + if (footerBox) footerBox.destroyRecursively() + + 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() +} diff --git a/packages/core/src/lib/FocusManager.ts b/packages/core/src/lib/FocusManager.ts new file mode 100644 index 000000000..a362b9bfd --- /dev/null +++ b/packages/core/src/lib/FocusManager.ts @@ -0,0 +1,118 @@ +import { getKeyHandler } from "./KeyHandler" +import type { Renderable } from "../Renderable" +import type { ParsedKey } from "./parse.keypress" +import type { CliRenderer } from "../renderer" + +export type FocusKeyHandler = (key: ParsedKey, focusNext: () => void, focusPrev: () => void) => void + +interface FocusManagerConfig { + onKey?: FocusKeyHandler +} + +export class FocusManager { + private static instance: FocusManager | null = null + + private keyUnsubscribe: (() => void) | null = null + private readonly renderer: CliRenderer + private onKey?: FocusKeyHandler + + static install(renderer: CliRenderer, config?: FocusManagerConfig): FocusManager { + if (this.instance) return this.instance + const mgr = new FocusManager(renderer, config) + this.instance = mgr + mgr.attach() + mgr.initFocus() + return mgr + } + + static uninstall(): void { + this.instance?.detach() + this.instance = null + } + + constructor(renderer: CliRenderer, config?: FocusManagerConfig) { + this.renderer = renderer + this.onKey = config?.onKey + } + + private getFocusables(): Renderable[] { + return this.renderer.focusables + } + + private isFocusable(r: Renderable): boolean { + return r["visible"] === true && r["focusable"] === true + } + + private attach(): void { + const keyHandler = getKeyHandler() + const keypress = (key: ParsedKey) => { + if (this.onKey) { + this.onKey( + key, + () => this.focusNext(), + () => this.focusPrev(), + ) + } else { + 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.renderer.focusedRenderable = null + this.renderer.focusables = [] + } + + private initFocus(): void { + const first = this.getFocusables().find((r) => this.isFocusable(r)) + if (first) { + this.renderer.focusedRenderable = first + first.focus() + } + } + + private findNextFocusable(): Renderable | null { + const focusables = this.getFocusables() + 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.isFocusable(focusables[i])) return focusables[i] + } + return focusables.find((r) => this.isFocusable(r)) ?? null + } + + private focusNext(): void { + const next = this.findNextFocusable() + if (!next) return + this.renderer.focusedRenderable?.blur() + this.renderer.focusedRenderable = next + next.focus() + } + + private findPrevFocusable(): Renderable | null { + const focusables = this.getFocusables() + 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.isFocusable(focusables[i])) return focusables[i] + } + return focusables.findLast((r) => this.isFocusable(r)) ?? null + } + + private focusPrev(): void { + const prev = this.findPrevFocusable() + if (!prev) return + this.renderer.focusedRenderable?.blur() + this.renderer.focusedRenderable = prev + prev.focus() + } +} diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index ac1453eac..7381785e8 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -8,3 +8,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 c530b0741..fa573e417 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -16,6 +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, type FocusKeyHandler } from "./lib/FocusManager" import { getObjectsInViewport } from "./lib/objects-in-viewport" import { KeyHandler } from "./lib/KeyHandler" @@ -36,6 +37,8 @@ export interface CliRendererConfig { useAlternateScreen?: boolean useConsole?: boolean experimental_splitHeight?: number + focusKeyHandler?: FocusKeyHandler + useFocusManager?: boolean useKittyKeyboard?: boolean } @@ -136,6 +139,11 @@ export async function createCliRenderer(config: CliRendererConfig = {}): Promise const renderer = new CliRenderer(ziglib, rendererPtr, stdin, stdout, width, height, config) await renderer.setupTerminal() + + if (config.useFocusManager ?? true) { + FocusManager.install(renderer, { onKey: config.focusKeyHandler }) + } + return renderer } @@ -219,6 +227,9 @@ 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 @@ -402,18 +413,55 @@ export class CliRenderer extends EventEmitter implements RenderContext { return this.lifecyclePasses } - public get currentFocusedRenderable(): Renderable | null { - return this._currentFocusedRenderable + private findParentInList(node: Renderable, list: Renderable[]): Renderable | undefined { + let current = node.parent + while (current) { + if (list.includes(current)) { + return current + } + current = current.parent + } + return undefined + } + + public addFocusable(node: Renderable): void { + if (this._focusables.includes(node)) return + + let index = 0 + + const parent = this.findParentInList(node, this._focusables) + if (parent) { + 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.findIndex((f) => f.id === node.id) + if (i !== -1) this._focusables.splice(i, 1) } - public focusRenderable(renderable: Renderable) { - if (this._currentFocusedRenderable === renderable) return + public get focusables(): Renderable[] { + return this._focusables + } - if (this._currentFocusedRenderable) { - this._currentFocusedRenderable.blur() + 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 + } - this._currentFocusedRenderable = renderable + public get focusedRenderable(): Renderable | null { + return this._focusedRenderable } public addToHitGrid(x: number, y: number, width: number, height: number, id: number) { @@ -1163,6 +1211,7 @@ export class CliRenderer extends EventEmitter implements RenderContext { this.waitingForPixelResolution = false this.capturedRenderable = undefined + FocusManager.uninstall() this.root.destroyRecursively() this._keyHandler.destroy() diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 3198b4d08..0693ceac3 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -47,11 +47,13 @@ export interface RenderContext extends EventEmitter { capabilities: any | null requestLive: () => void dropLive: () => void + focusedRenderable: Renderable | null hasSelection: boolean getSelection: () => Selection | null requestSelectionUpdate: () => void - currentFocusedRenderable: Renderable | null - focusRenderable: (renderable: Renderable) => void + focusables: Renderable[] + removeFocusable: (renderable: Renderable) => void + addFocusable: (node: Renderable) => void registerLifecyclePass: (renderable: Renderable) => void unregisterLifecyclePass: (renderable: Renderable) => void getLifecyclePasses: () => Set