Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
5d39427
tabindex
ReverseGem7 Sep 3, 2025
f03e13c
Remove console log in FocusManager
ReverseGem7 Sep 3, 2025
5d91425
Run prettier
ReverseGem7 Sep 3, 2025
6d87444
Update the YGTreeWalker to reset its current node on tree changes
ReverseGem7 Sep 4, 2025
0fe4ec3
Remove unused console log
ReverseGem7 Sep 4, 2025
c521525
Merge branch 'sst:main' into tabindex
ReverseGem7 Sep 4, 2025
7d70482
remove unused imports and variables in input-select-layout-demo
ReverseGem7 Sep 4, 2025
1d5d719
Add focusKeyHandler config to createCliRenderer
ReverseGem7 Sep 4, 2025
b25d550
remove keyboard navigation on CliRenderer.destroy
ReverseGem7 Sep 4, 2025
dd44a03
Implement focusedRenderable in RenderContext
ReverseGem7 Sep 4, 2025
db1a750
Merge branch 'sst:main' into tabindex
ReverseGem7 Sep 4, 2025
288f7e3
Refactor FocusManager to use globalEmitter for tree change events
ReverseGem7 Sep 4, 2025
46b5609
Add Nested Input Tree Demo
ReverseGem7 Sep 4, 2025
3aa4663
fix
ReverseGem7 Sep 4, 2025
c5d795d
randomizing input and sublevel distribution based on depth on nested-…
ReverseGem7 Sep 4, 2025
2285ab1
Remove keyboard navigation handler from input-demo
ReverseGem7 Sep 4, 2025
deca42f
Add enableFocusManager option to CliRendererConfig
ReverseGem7 Sep 4, 2025
ead817a
rename enableFocusManager to useFocusManager in CliRendererConfig
ReverseGem7 Sep 4, 2025
47d555f
run prettier
ReverseGem7 Sep 4, 2025
d137485
Merge branch 'sst:main' into tabindex
ReverseGem7 Sep 5, 2025
85c69c8
Remove TreeWalker
ReverseGem7 Sep 9, 2025
a61ded8
Merge branch 'sst:main' into tabindex
ReverseGem7 Sep 9, 2025
fbbc2d1
fix
ReverseGem7 Sep 9, 2025
eff42b3
remove generator
ReverseGem7 Sep 10, 2025
3c05b20
Merge branch 'sst:main' into tabindex
ReverseGem7 Sep 10, 2025
81615c4
fix
ReverseGem7 Sep 10, 2025
3c81652
fix
ReverseGem7 Sep 10, 2025
c8920ad
fix
ReverseGem7 Sep 11, 2025
c188816
Merge branch 'sst:main' into tabindex
ReverseGem7 Sep 11, 2025
51cbcf3
run prettier
ReverseGem7 Sep 11, 2025
3c91754
Merge branch 'sst:main' into tabindex
ReverseGem7 Sep 19, 2025
856c404
fix
ReverseGem7 Sep 19, 2025
0128e5a
fix
ReverseGem7 Sep 19, 2025
49b911f
Merge branch 'sst:main' into tabindex
ReverseGem7 Sep 21, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/core/src/Renderable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/examples/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -419,7 +426,6 @@ class ExampleSelector {
case "\u0003":
this.cleanup()
process.exit()
break
}
switch (key.name) {
case "c":
Expand Down Expand Up @@ -461,6 +467,7 @@ class ExampleSelector {
this.selectBox.visible = false
}
if (this.selectElement) {
this.selectElement.visible = false
this.selectElement.blur()
}
}
Expand All @@ -472,6 +479,7 @@ class ExampleSelector {
this.selectBox.visible = true
}
if (this.selectElement) {
this.selectElement.visible = true
this.selectElement.focus()
}
}
Expand Down
46 changes: 0 additions & 46 deletions packages/core/src/examples/input-demo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 3 additions & 41 deletions packages/core/src/examples/input-select-layout-demo.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<InputRenderable | SelectRenderable> = []
const focusableBoxes: Array<BoxRenderable | null> = []

const colorOptions: SelectOption[] = [
{ name: "Red", description: "A warm primary color", value: "#ff0000" },
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/examples/lib/standalone-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
136 changes: 136 additions & 0 deletions packages/core/src/examples/nested-input-tree.ts
Original file line number Diff line number Diff line change
@@ -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()
}
Loading