OpenTUI is here (#2685)

This commit is contained in:
Dax
2025-10-31 15:07:36 -04:00
committed by GitHub
parent 81c617770d
commit 96bdeb3c7b
104 changed files with 8459 additions and 716 deletions
@@ -0,0 +1,171 @@
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid"
import { batch, createContext, createEffect, Show, useContext, type JSX, type ParentProps } from "solid-js"
import { useTheme } from "@tui/context/theme"
import { Renderable, RGBA } from "@opentui/core"
import { createStore } from "solid-js/store"
import { createEventBus } from "@solid-primitives/event-bus"
const Border = {
topLeft: "┃",
topRight: "┃",
bottomLeft: "┃",
bottomRight: "┃",
horizontal: "",
vertical: "┃",
topT: "+",
bottomT: "+",
leftT: "+",
rightT: "+",
cross: "+",
}
export function Dialog(
props: ParentProps<{
size?: "medium" | "large"
onClose: () => void
}>,
) {
const dimensions = useTerminalDimensions()
const { theme } = useTheme()
return (
<box
onMouseUp={async () => {
props.onClose?.()
}}
width={dimensions().width}
height={dimensions().height}
alignItems="center"
position="absolute"
paddingTop={dimensions().height / 4}
left={0}
top={0}
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
>
<box
onMouseUp={async (e) => {
e.stopPropagation()
}}
customBorderChars={Border}
width={props.size === "large" ? 80 : 60}
maxWidth={dimensions().width - 2}
backgroundColor={theme.backgroundPanel}
borderColor={theme.border}
paddingTop={1}
>
{props.children}
</box>
</box>
)
}
function init() {
const [store, setStore] = createStore({
stack: [] as {
element: JSX.Element
onClose?: () => void
}[],
size: "medium" as "medium" | "large",
})
const allClosedEvent = createEventBus<void>()
useKeyboard((evt) => {
if (evt.name === "escape" && store.stack.length > 0) {
const current = store.stack.at(-1)!
current.onClose?.()
setStore("stack", store.stack.slice(0, -1))
evt.preventDefault()
refocus()
}
})
const renderer = useRenderer()
let focus: Renderable | null
function refocus() {
setTimeout(() => {
if (!focus) return
if (focus.isDestroyed) return
function find(item: Renderable) {
for (const child of item.getChildren()) {
if (child === focus) return true
if (find(child)) return true
}
return false
}
const found = find(renderer.root)
if (!found) return
focus.focus()
}, 1)
}
createEffect(() => {
if (store.stack.length === 0) {
allClosedEvent.emit()
}
})
return {
clear() {
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
batch(() => {
setStore("size", "medium")
setStore("stack", [])
})
refocus()
},
replace(input: any, onClose?: () => void) {
if (store.stack.length === 0) focus = renderer.currentFocusedRenderable
for (const item of store.stack) {
if (item.onClose) item.onClose()
}
setStore("size", "medium")
setStore("stack", [
{
element: input,
onClose,
},
])
},
get stack() {
return store.stack
},
get size() {
return store.size
},
setSize(size: "medium" | "large") {
setStore("size", size)
},
get allClosedEvent() {
return allClosedEvent
}
}
}
export type DialogContext = ReturnType<typeof init>
const ctx = createContext<DialogContext>()
export function DialogProvider(props: ParentProps) {
const value = init()
return (
<ctx.Provider value={value}>
{props.children}
<box position="absolute">
<Show when={value.stack.length}>
<Dialog onClose={() => value.clear()} size={value.size}>
{value.stack.at(-1)!.element}
</Dialog>
</Show>
</box>
</ctx.Provider>
)
}
export function useDialog() {
const value = useContext(ctx)
if (!value) {
throw new Error("useDialog must be used within a DialogProvider")
}
return value
}