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

136 lines
3.4 KiB
TypeScript
Raw Normal View History

2026-08-13 13:46:53 +09:00
import {
BoxRenderable,
RGBA,
TextAttributes,
TextRenderable,
type CliRenderer,
} from "@opentui/core"
export interface PickerMenuTheme {
text: string
muted: string
border: string
}
interface PickerMenuOptions<T> {
id: string
searchText: (item: T) => string
render: (item: T) => string
emptyText?: string
}
/** 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%",
flexShrink: 0,
flexDirection: "column",
border: true,
borderStyle: "rounded",
borderColor: theme.border,
paddingLeft: 1,
paddingRight: 1,
backgroundColor: RGBA.defaultBackground(),
visible: false,
})
}
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)
}
update(query: string, limit = this.limit): void {
if (!this.visible) return
const changed = query !== this.query
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)
this.selected = changed ? 0 : Math.min(this.selected, Math.max(0, this.matches.length - 1))
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,
}))
return
}
for (const [index, item] of this.matches.entries()) {
const selected = index === this.selected
this.root.add(new TextRenderable(this.renderer, {
id: `${this.options.id}-${index}`,
content: `${selected ? "" : " "} ${this.options.render(item)}`,
width: "100%",
height: 1,
wrapMode: "none",
fg: selected ? this.theme.text : this.theme.muted,
attributes: selected ? TextAttributes.BOLD : 0,
}))
}
}
private clear(): void {
for (const child of [...this.root.getChildren()]) {
this.root.remove(child)
child.destroyRecursively()
}
}
}