249 lines
6.5 KiB
TypeScript
249 lines
6.5 KiB
TypeScript
import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
|
import { batch, createContext, createEffect, onCleanup, Show, useContext, type JSX, type ParentProps } from "solid-js"
|
|
import { Keymap } from "../context/keymap"
|
|
import { useTheme } from "../context/theme"
|
|
import { MouseButton, Renderable, RGBA } from "@opentui/core"
|
|
import { createStore } from "solid-js/store"
|
|
import { useToast } from "./toast"
|
|
import { useClipboard } from "../context/clipboard"
|
|
import { useConfig } from "../config"
|
|
|
|
export type DialogSize = "medium" | "large" | "xlarge"
|
|
|
|
export function dialogWidth(size: DialogSize) {
|
|
if (size === "xlarge") return 116
|
|
if (size === "large") return 88
|
|
return 60
|
|
}
|
|
|
|
export function Dialog(
|
|
props: ParentProps<{
|
|
size?: DialogSize
|
|
centered?: boolean
|
|
onClose: () => void
|
|
}>,
|
|
) {
|
|
const dimensions = useTerminalDimensions()
|
|
const theme = useTheme("elevated")
|
|
const renderer = useRenderer()
|
|
|
|
let dismiss = false
|
|
return (
|
|
<box
|
|
onMouseDown={() => {
|
|
dismiss = !!renderer.getSelection()
|
|
}}
|
|
onMouseUp={() => {
|
|
if (dismiss) {
|
|
dismiss = false
|
|
return
|
|
}
|
|
props.onClose?.()
|
|
}}
|
|
width={dimensions().width}
|
|
height={dimensions().height}
|
|
alignItems="center"
|
|
justifyContent={props.centered ? "center" : undefined}
|
|
position="absolute"
|
|
zIndex={3000}
|
|
paddingTop={props.centered ? 0 : dimensions().height / 4}
|
|
left={0}
|
|
top={0}
|
|
backgroundColor={RGBA.fromInts(0, 0, 0, 150)}
|
|
>
|
|
<box
|
|
onMouseUp={(e: { stopPropagation(): void }) => {
|
|
// A selection release must bubble up to the copy-on-select handler in
|
|
// DialogProvider; the backdrop's dismiss flag keeps it from closing the dialog.
|
|
if (renderer.getSelection()?.getSelectedText()) return
|
|
dismiss = false
|
|
e.stopPropagation()
|
|
}}
|
|
width={dialogWidth(props.size ?? "medium")}
|
|
maxWidth={dimensions().width - 2}
|
|
backgroundColor={theme.background.default}
|
|
paddingTop={1}
|
|
>
|
|
{props.children}
|
|
</box>
|
|
</box>
|
|
)
|
|
}
|
|
|
|
function init() {
|
|
const [store, setStore] = createStore({
|
|
stack: [] as {
|
|
element: JSX.Element
|
|
onClose?: () => void
|
|
}[],
|
|
size: "medium" as DialogSize,
|
|
centered: false,
|
|
})
|
|
|
|
const renderer = useRenderer()
|
|
const keymap = Keymap.use()
|
|
|
|
createEffect(() => {
|
|
if (store.stack.length === 0) return
|
|
const popMode = keymap.mode.push("modal")
|
|
onCleanup(popMode)
|
|
})
|
|
|
|
let focus: Renderable | null
|
|
function refocus() {
|
|
setTimeout(() => {
|
|
if (store.stack.length > 0) return
|
|
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)
|
|
}
|
|
|
|
Keymap.createLayer(() => ({
|
|
mode: "modal",
|
|
enabled: store.stack.length > 0 && !renderer.getSelection()?.getSelectedText(),
|
|
commands: [
|
|
{
|
|
bind: "escape",
|
|
title: "Close dialog",
|
|
group: "Dialog",
|
|
run: () => {
|
|
if (renderer.getSelection()) {
|
|
renderer.clearSelection()
|
|
}
|
|
const current = store.stack.at(-1)
|
|
current?.onClose?.()
|
|
setStore("stack", store.stack.slice(0, -1))
|
|
refocus()
|
|
},
|
|
},
|
|
{
|
|
bind: "ctrl+c",
|
|
title: "Close dialog",
|
|
group: "Dialog",
|
|
run: () => {
|
|
if (renderer.getSelection()) {
|
|
renderer.clearSelection()
|
|
}
|
|
const current = store.stack.at(-1)
|
|
current?.onClose?.()
|
|
setStore("stack", store.stack.slice(0, -1))
|
|
refocus()
|
|
},
|
|
},
|
|
],
|
|
}))
|
|
|
|
return {
|
|
clear() {
|
|
for (const item of store.stack) {
|
|
if (item.onClose) item.onClose()
|
|
}
|
|
batch(() => {
|
|
setStore("size", "medium")
|
|
setStore("centered", false)
|
|
setStore("stack", [])
|
|
})
|
|
refocus()
|
|
},
|
|
replace(input: any, onClose?: () => void) {
|
|
if (store.stack.length === 0) {
|
|
focus = renderer.currentFocusedRenderable
|
|
focus?.blur()
|
|
}
|
|
for (const item of store.stack) {
|
|
if (item.onClose) item.onClose()
|
|
}
|
|
setStore("size", "medium")
|
|
setStore("centered", false)
|
|
setStore("stack", [
|
|
{
|
|
element: input,
|
|
onClose,
|
|
},
|
|
])
|
|
},
|
|
get stack() {
|
|
return store.stack
|
|
},
|
|
get size() {
|
|
return store.size
|
|
},
|
|
get centered() {
|
|
return store.centered
|
|
},
|
|
setSize(size: "medium" | "large" | "xlarge") {
|
|
setStore("size", size)
|
|
},
|
|
setCentered(centered: boolean) {
|
|
setStore("centered", centered)
|
|
},
|
|
}
|
|
}
|
|
|
|
export type DialogContext = ReturnType<typeof init>
|
|
|
|
const ctx = createContext<DialogContext>()
|
|
|
|
export function DialogProvider(props: ParentProps) {
|
|
const value = init()
|
|
const renderer = useRenderer()
|
|
const toast = useToast()
|
|
const clipboard = useClipboard()
|
|
const config = useConfig()
|
|
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
|
|
|
|
function copySelection() {
|
|
const text = renderer.getSelection()?.getSelectedText()
|
|
if (!text || !clipboard.write) return false
|
|
void clipboard.write(text).then(
|
|
() => toast.show({ message: "Copied to clipboard", variant: "info" }),
|
|
(error) => toast.error(error),
|
|
)
|
|
renderer.clearSelection()
|
|
return true
|
|
}
|
|
|
|
return (
|
|
<ctx.Provider value={value}>
|
|
{props.children}
|
|
<box
|
|
position="absolute"
|
|
zIndex={3000}
|
|
onMouseDown={(evt: { button: number; preventDefault(): void; stopPropagation(): void }) => {
|
|
if (copyOnSelectEnabled()) return
|
|
if (evt.button !== MouseButton.RIGHT) return
|
|
|
|
if (!copySelection()) return
|
|
evt.preventDefault()
|
|
evt.stopPropagation()
|
|
}}
|
|
onMouseUp={copyOnSelectEnabled() ? copySelection : undefined}
|
|
>
|
|
<Show when={value.stack.length}>
|
|
<Dialog onClose={() => value.clear()} size={value.size} centered={value.centered}>
|
|
{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
|
|
}
|