mirror of
https://github.com/aphrody-labs/opentui.git
synced 2026-10-13 21:08:53 +00:00
TypeScript used to own the renderable tree. Each frame walked it in JavaScript, read layout back from Yoga, and made one FFI call per draw, so frame cost grew with node and draw count. Native objects lived in global registries and crossed FFI as raw pointers, which made renderer teardown fragile. A Context-owned Scene now holds the retained tree, its Yoga nodes, and its paint state. Renderables stage property changes and flush them in one call per frame. Zig lays out, culls, and paints the built-in widgets, fills the hit grid, and hands the frame to Session for the terminal diff. A frame without paint hooks completes in one step call. Paint hooks run in one batch before painting and record into a byte stream that native replays in paint order, so painting never pauses for JavaScript and a hook cannot tear the frame. Every native resource sits behind a generational handle, so a stale handle fails with an error instead of touching freed memory, and destroying a Context releases everything it owns.
319 lines
9.5 KiB
TypeScript
319 lines
9.5 KiB
TypeScript
import { createCliRenderer, DiffRenderable, parseColor, RenderableEvents, SyntaxStyle } from "@opentui/core"
|
|
import { createRoot, useKeyboard, useRenderer } from "@opentui/react"
|
|
import { useState, useLayoutEffect, useRef } from "react"
|
|
|
|
interface DiffTheme {
|
|
name: string
|
|
backgroundColor: string
|
|
borderColor: string
|
|
addedBg: string
|
|
removedBg: string
|
|
contextBg: string
|
|
addedSignColor: string
|
|
removedSignColor: string
|
|
lineNumberFg: string
|
|
lineNumberBg: string
|
|
addedLineNumberBg: string
|
|
removedLineNumberBg: string
|
|
selectionBg: string
|
|
selectionFg: string
|
|
syntaxStyle: Parameters<typeof SyntaxStyle.fromStyles>[0]
|
|
}
|
|
|
|
const themes: DiffTheme[] = [
|
|
{
|
|
name: "GitHub Dark",
|
|
backgroundColor: "#0D1117",
|
|
borderColor: "#4ECDC4",
|
|
addedBg: "#1a4d1a",
|
|
removedBg: "#4d1a1a",
|
|
contextBg: "transparent",
|
|
addedSignColor: "#22c55e",
|
|
removedSignColor: "#ef4444",
|
|
lineNumberFg: "#6b7280",
|
|
lineNumberBg: "#161b22",
|
|
addedLineNumberBg: "#0d3a0d",
|
|
removedLineNumberBg: "#3a0d0d",
|
|
selectionBg: "#264F78",
|
|
selectionFg: "#FFFFFF",
|
|
syntaxStyle: {
|
|
keyword: { fg: parseColor("#FF7B72"), bold: true },
|
|
"keyword.import": { fg: parseColor("#FF7B72"), bold: true },
|
|
string: { fg: parseColor("#A5D6FF") },
|
|
comment: { fg: parseColor("#8B949E"), italic: true },
|
|
number: { fg: parseColor("#79C0FF") },
|
|
boolean: { fg: parseColor("#79C0FF") },
|
|
constant: { fg: parseColor("#79C0FF") },
|
|
function: { fg: parseColor("#D2A8FF") },
|
|
"function.call": { fg: parseColor("#D2A8FF") },
|
|
constructor: { fg: parseColor("#FFA657") },
|
|
type: { fg: parseColor("#FFA657") },
|
|
operator: { fg: parseColor("#FF7B72") },
|
|
variable: { fg: parseColor("#E6EDF3") },
|
|
property: { fg: parseColor("#79C0FF") },
|
|
bracket: { fg: parseColor("#F0F6FC") },
|
|
punctuation: { fg: parseColor("#F0F6FC") },
|
|
default: { fg: parseColor("#E6EDF3") },
|
|
},
|
|
},
|
|
{
|
|
name: "Monokai",
|
|
backgroundColor: "#272822",
|
|
borderColor: "#FD971F",
|
|
addedBg: "#2d4a2b",
|
|
removedBg: "#4a2b2b",
|
|
contextBg: "transparent",
|
|
addedSignColor: "#A6E22E",
|
|
removedSignColor: "#F92672",
|
|
lineNumberFg: "#75715E",
|
|
lineNumberBg: "#1e1f1c",
|
|
addedLineNumberBg: "#1e3a1e",
|
|
removedLineNumberBg: "#3a1e1e",
|
|
selectionBg: "#49483E",
|
|
selectionFg: "#F8F8F2",
|
|
syntaxStyle: {
|
|
keyword: { fg: parseColor("#F92672"), bold: true },
|
|
"keyword.import": { fg: parseColor("#F92672"), bold: true },
|
|
string: { fg: parseColor("#E6DB74") },
|
|
comment: { fg: parseColor("#75715E"), italic: true },
|
|
number: { fg: parseColor("#AE81FF") },
|
|
boolean: { fg: parseColor("#AE81FF") },
|
|
constant: { fg: parseColor("#AE81FF") },
|
|
function: { fg: parseColor("#A6E22E") },
|
|
"function.call": { fg: parseColor("#A6E22E") },
|
|
constructor: { fg: parseColor("#FD971F") },
|
|
type: { fg: parseColor("#66D9EF") },
|
|
operator: { fg: parseColor("#F92672") },
|
|
variable: { fg: parseColor("#F8F8F2") },
|
|
property: { fg: parseColor("#66D9EF") },
|
|
bracket: { fg: parseColor("#F8F8F2") },
|
|
punctuation: { fg: parseColor("#F8F8F2") },
|
|
default: { fg: parseColor("#F8F8F2") },
|
|
},
|
|
},
|
|
{
|
|
name: "Dracula",
|
|
backgroundColor: "#282A36",
|
|
borderColor: "#BD93F9",
|
|
addedBg: "#2d4737",
|
|
removedBg: "#4d2d37",
|
|
contextBg: "transparent",
|
|
addedSignColor: "#50FA7B",
|
|
removedSignColor: "#FF5555",
|
|
lineNumberFg: "#6272A4",
|
|
lineNumberBg: "#21222C",
|
|
addedLineNumberBg: "#1f3626",
|
|
removedLineNumberBg: "#3a2328",
|
|
selectionBg: "#44475A",
|
|
selectionFg: "#F8F8F2",
|
|
syntaxStyle: {
|
|
keyword: { fg: parseColor("#FF79C6"), bold: true },
|
|
"keyword.import": { fg: parseColor("#FF79C6"), bold: true },
|
|
string: { fg: parseColor("#F1FA8C") },
|
|
comment: { fg: parseColor("#6272A4"), italic: true },
|
|
number: { fg: parseColor("#BD93F9") },
|
|
boolean: { fg: parseColor("#BD93F9") },
|
|
constant: { fg: parseColor("#BD93F9") },
|
|
function: { fg: parseColor("#50FA7B") },
|
|
"function.call": { fg: parseColor("#50FA7B") },
|
|
constructor: { fg: parseColor("#FFB86C") },
|
|
type: { fg: parseColor("#8BE9FD") },
|
|
operator: { fg: parseColor("#FF79C6") },
|
|
variable: { fg: parseColor("#F8F8F2") },
|
|
property: { fg: parseColor("#8BE9FD") },
|
|
bracket: { fg: parseColor("#F8F8F2") },
|
|
punctuation: { fg: parseColor("#F8F8F2") },
|
|
default: { fg: parseColor("#F8F8F2") },
|
|
},
|
|
},
|
|
]
|
|
|
|
const exampleDiff = `--- a/calculator.ts
|
|
+++ b/calculator.ts
|
|
@@ -1,13 +1,20 @@
|
|
class Calculator {
|
|
add(a: number, b: number): number {
|
|
return a + b;
|
|
}
|
|
|
|
- subtract(a: number, b: number): number {
|
|
- return a - b;
|
|
+ subtract(a: number, b: number, c: number = 0): number {
|
|
+ return a - b - c;
|
|
}
|
|
|
|
multiply(a: number, b: number): number {
|
|
return a * b;
|
|
}
|
|
+
|
|
+ divide(a: number, b: number): number {
|
|
+ if (b === 0) {
|
|
+ throw new Error("Division by zero");
|
|
+ }
|
|
+ return a / b;
|
|
+ }
|
|
}`
|
|
|
|
const HelpModal = ({ theme, visible }: { theme: DiffTheme; visible: boolean }) => {
|
|
if (!visible) return null
|
|
|
|
return (
|
|
<box
|
|
style={{
|
|
position: "absolute",
|
|
left: "50%",
|
|
top: "50%",
|
|
width: 60,
|
|
height: 14,
|
|
marginLeft: -30,
|
|
marginTop: -7,
|
|
border: true,
|
|
borderStyle: "double",
|
|
borderColor: theme.borderColor,
|
|
backgroundColor: theme.backgroundColor,
|
|
padding: 2,
|
|
zIndex: 100,
|
|
}}
|
|
title="Keybindings"
|
|
titleAlignment="center"
|
|
>
|
|
<text
|
|
content={`View Controls:
|
|
V : Toggle view mode (Unified/Split)
|
|
L : Toggle line numbers
|
|
W : Toggle wrap mode (None/Word)
|
|
|
|
Theme & Display:
|
|
T : Cycle through themes
|
|
|
|
Other:
|
|
? : Toggle this help screen
|
|
Ctrl+C : Exit`}
|
|
style={{ fg: "#E6EDF3" }}
|
|
/>
|
|
</box>
|
|
)
|
|
}
|
|
|
|
export function App() {
|
|
const renderer = useRenderer()
|
|
const diffRef = useRef<DiffRenderable | null>(null)
|
|
const ownedStyle = useRef<SyntaxStyle | null>(null)
|
|
const [syntaxStyle, setSyntaxStyle] = useState<SyntaxStyle | null>(null)
|
|
const [themeIndex, setThemeIndex] = useState(0)
|
|
const [view, setView] = useState<"unified" | "split">("unified")
|
|
const [showLineNumbers, setShowLineNumbers] = useState(true)
|
|
const [wrapMode, setWrapMode] = useState<"none" | "word">("none")
|
|
const [showHelp, setShowHelp] = useState(false)
|
|
|
|
const theme = themes[themeIndex]
|
|
useLayoutEffect(() => {
|
|
const style = SyntaxStyle.fromStyles(theme.syntaxStyle, renderer.nativeScene!)
|
|
const previous = ownedStyle.current
|
|
ownedStyle.current = style
|
|
try {
|
|
const diff = diffRef.current
|
|
if (diff && !diff.isDestroyed) diff.syntaxStyle = style
|
|
setSyntaxStyle(style)
|
|
} finally {
|
|
previous?.destroy()
|
|
}
|
|
}, [renderer, theme])
|
|
|
|
useLayoutEffect(() => {
|
|
return () => {
|
|
const style = ownedStyle.current
|
|
ownedStyle.current = null
|
|
if (!style) return
|
|
const diff = diffRef.current
|
|
if (diff && !diff.isDestroyed) {
|
|
// Diff destroys its detached panes after emitting its own destroy event.
|
|
diff.prependOnceListener(RenderableEvents.DESTROYED, () => queueMicrotask(() => style.destroy()))
|
|
} else {
|
|
style.destroy()
|
|
}
|
|
}
|
|
}, [renderer])
|
|
|
|
useKeyboard((key) => {
|
|
if (key.raw === "?") {
|
|
setShowHelp((prev) => !prev)
|
|
return
|
|
}
|
|
|
|
if (showHelp) return
|
|
|
|
if (key.name === "v" && !key.ctrl && !key.meta) {
|
|
setView((prev) => (prev === "unified" ? "split" : "unified"))
|
|
} else if (key.name === "l" && !key.ctrl && !key.meta) {
|
|
setShowLineNumbers((prev) => !prev)
|
|
} else if (key.name === "w" && !key.ctrl && !key.meta) {
|
|
setWrapMode((prev) => (prev === "none" ? "word" : "none"))
|
|
} else if (key.name === "t" && !key.ctrl && !key.meta) {
|
|
setThemeIndex((prev) => (prev + 1) % themes.length)
|
|
}
|
|
})
|
|
|
|
if (!syntaxStyle) return null
|
|
|
|
return (
|
|
<box
|
|
style={{
|
|
padding: 1,
|
|
backgroundColor: theme.backgroundColor,
|
|
zIndex: 10,
|
|
}}
|
|
>
|
|
<box
|
|
title={`Diff Demo - ${theme.name}`}
|
|
titleAlignment="center"
|
|
style={{
|
|
height: 3,
|
|
border: true,
|
|
borderStyle: "double",
|
|
borderColor: theme.borderColor,
|
|
backgroundColor: theme.backgroundColor,
|
|
}}
|
|
>
|
|
<text content="Ctrl+C to exit | Press ? for keybindings" style={{ fg: "#888888" }} />
|
|
</box>
|
|
|
|
<diff
|
|
ref={(node) => {
|
|
// React clears refs before passive host destruction.
|
|
if (node) diffRef.current = node
|
|
}}
|
|
diff={exampleDiff}
|
|
view={view}
|
|
filetype="typescript"
|
|
syntaxStyle={syntaxStyle}
|
|
showLineNumbers={showLineNumbers}
|
|
wrapMode={wrapMode}
|
|
addedBg={theme.addedBg}
|
|
removedBg={theme.removedBg}
|
|
contextBg={theme.contextBg}
|
|
addedSignColor={theme.addedSignColor}
|
|
removedSignColor={theme.removedSignColor}
|
|
lineNumberFg={theme.lineNumberFg}
|
|
lineNumberBg={theme.lineNumberBg}
|
|
addedLineNumberBg={theme.addedLineNumberBg}
|
|
removedLineNumberBg={theme.removedLineNumberBg}
|
|
selectionBg={theme.selectionBg}
|
|
selectionFg={theme.selectionFg}
|
|
style={{
|
|
flexGrow: 1,
|
|
flexShrink: 1,
|
|
}}
|
|
/>
|
|
|
|
<HelpModal theme={theme} visible={showHelp} />
|
|
</box>
|
|
)
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const renderer = await createCliRenderer({ exitOnCtrlC: true, targetFps: 60 })
|
|
renderer.setBackgroundColor("#0D1117")
|
|
createRoot(renderer).render(<App />)
|
|
}
|