Files
nanobot/tui/src/picker-menu.ts
T

207 lines
5.5 KiB
TypeScript
Raw Normal View History

2026-08-13 13:46:53 +09:00
import {
BoxRenderable,
RGBA,
2026-08-23 23:20:17 +08:00
StyledText,
2026-08-13 13:46:53 +09:00
TextAttributes,
TextRenderable,
type CliRenderer,
2026-08-23 23:20:17 +08:00
type TextChunk,
2026-08-13 13:46:53 +09:00
} from "@opentui/core"
export interface PickerMenuTheme {
text: string
muted: string
border: string
2026-08-23 23:20:17 +08:00
accent?: string
warning?: string
2026-08-16 20:27:57 +08:00
selectedBackground?: string
2026-08-13 13:46:53 +09:00
}
interface PickerMenuOptions<T> {
id: string
2026-08-23 23:28:01 +08:00
key?: (item: T) => string
2026-08-13 13:46:53 +09:00
searchText: (item: T) => string
2026-08-23 23:20:17 +08:00
render: (item: T, selected: boolean) => string | TextChunk[]
2026-08-13 13:46:53 +09:00
emptyText?: string
2026-08-16 20:27:57 +08:00
maxWidth?: number
onSelect?: (item: T) => void
2026-08-13 13:46:53 +09:00
}
/** Shared retained picker for command discovery and session navigation. */
export class PickerMenu<T> {
readonly root: BoxRenderable
private items: T[] = []
private matches: T[] = []
private selected = 0
private query = ""
private limit = 6
constructor(
private readonly renderer: CliRenderer,
private theme: PickerMenuTheme,
private readonly options: PickerMenuOptions<T>,
) {
this.root = new BoxRenderable(renderer, {
id: options.id,
width: "100%",
2026-08-16 20:27:57 +08:00
...(options.maxWidth ? { maxWidth: options.maxWidth } : {}),
2026-08-13 13:46:53 +09:00
flexShrink: 0,
flexDirection: "column",
border: true,
borderStyle: "rounded",
borderColor: theme.border,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: RGBA.defaultBackground(),
visible: false,
2026-08-16 21:40:53 +08:00
onMouseDown: (event) => {
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation()
this.renderer.clearSelection()
},
2026-08-13 13:46:53 +09:00
})
}
get visible(): boolean {
return this.root.visible
}
show(items: T[], query = "", limit = 6): void {
this.items = items
this.root.visible = true
this.update(query, limit)
}
2026-08-23 23:20:17 +08:00
replace(items: T[]): void {
if (!this.visible) return
this.items = items
this.update(this.query, this.limit)
}
redraw(): void {
if (this.visible) this.render()
}
2026-08-13 13:46:53 +09:00
update(query: string, limit = this.limit): void {
if (!this.visible) return
const changed = query !== this.query
2026-08-23 23:28:01 +08:00
const previous = this.matches[this.selected]
const previousKey = previous === undefined ? null : this.options.key?.(previous)
2026-08-13 13:46:53 +09:00
this.query = query
this.limit = Math.max(1, limit)
const words = query.trim().toLocaleLowerCase().split(/\s+/u).filter(Boolean)
this.matches = this.items
.filter((item) => {
const haystack = this.options.searchText(item).toLocaleLowerCase()
return words.every((word) => haystack.includes(word))
})
.slice(0, this.limit)
2026-08-23 23:28:01 +08:00
if (changed) {
this.selected = 0
} else {
const preserved = previous === undefined
? -1
: previousKey === null || previousKey === undefined
? this.matches.indexOf(previous)
: this.matches.findIndex((item) => this.options.key?.(item) === previousKey)
this.selected = preserved >= 0
? preserved
: Math.min(this.selected, Math.max(0, this.matches.length - 1))
}
2026-08-13 13:46:53 +09:00
this.render()
}
move(direction: -1 | 1): boolean {
if (!this.visible || this.matches.length < 2) return false
this.selected = (this.selected + direction + this.matches.length) % this.matches.length
this.render()
return true
}
current(): T | null {
return this.visible ? this.matches[this.selected] ?? null : null
}
hide(): void {
this.items = []
this.matches = []
this.selected = 0
this.query = ""
this.root.visible = false
this.clear()
}
setTheme(theme: PickerMenuTheme): void {
this.theme = theme
this.root.borderColor = theme.border
if (this.visible) this.render()
}
private render(): void {
this.clear()
if (this.matches.length === 0) {
this.root.add(new TextRenderable(this.renderer, {
id: `${this.options.id}-empty`,
content: this.options.emptyText || "No matches",
width: "100%",
height: 1,
fg: this.theme.muted,
2026-08-16 21:54:02 +08:00
selectable: false,
2026-08-13 13:46:53 +09:00
}))
return
}
for (const [index, item] of this.matches.entries()) {
const selected = index === this.selected
2026-08-23 23:20:17 +08:00
const rendered = this.options.render(item, selected)
const content = typeof rendered === "string"
? `${selected ? "" : " "} ${rendered}`
: new StyledText([
chunk(`${selected ? "" : " "} `, selected ? this.theme.text : this.theme.muted),
...rendered,
])
2026-08-13 13:46:53 +09:00
this.root.add(new TextRenderable(this.renderer, {
id: `${this.options.id}-${index}`,
2026-08-23 23:20:17 +08:00
content,
2026-08-13 13:46:53 +09:00
width: "100%",
height: 1,
wrapMode: "none",
fg: selected ? this.theme.text : this.theme.muted,
2026-08-16 21:54:02 +08:00
selectable: false,
2026-08-16 20:27:57 +08:00
...(selected && this.theme.selectedBackground
? { backgroundColor: RGBA.fromHex(this.theme.selectedBackground) }
: {}),
2026-08-13 13:46:53 +09:00
attributes: selected ? TextAttributes.BOLD : 0,
onMouseMove: () => {
2026-08-16 20:27:57 +08:00
if (this.selected === index) return
this.selected = index
this.render()
},
onMouseDown: (event) => {
if (event.button !== 0) return
event.preventDefault()
event.stopPropagation()
2026-08-16 21:40:53 +08:00
this.renderer.clearSelection()
2026-08-16 20:27:57 +08:00
this.selected = index
this.options.onSelect?.(item)
},
2026-08-13 13:46:53 +09:00
}))
}
}
private clear(): void {
for (const child of [...this.root.getChildren()]) {
this.root.remove(child)
child.destroyRecursively()
}
}
}
2026-08-23 23:20:17 +08:00
function chunk(text: string, color: string): TextChunk {
return {
__isChunk: true,
text,
fg: RGBA.fromHex(color),
}
}