Files
Tommy D. Rossi 9932d50d9b Add native GPUI runtime and text editor backend
Run GPUI through its native platform architecture on macOS, Windows, Linux, and FreeBSD. Keep the embedded AppKit pump on macOS and route non-macOS commands through a dedicated GPUI UI thread.

Replace the painted input label with a platform-backed editor model. Add multiline layout, IME ranges, grapheme-safe navigation, caret and selection painting, clipboard actions, undo and redo, horizontal and vertical scrolling, native focus control, and textarea registration.

Update the native test renderer, generated declarations, platform dependencies, submodule target, and development-loop documentation to match the new runtime.

Session: ses_fd70de910ffe1g2yG1yAkCjeLu
2026-08-22 13:48:56 +02:00

155 lines
4.8 KiB
TypeScript

/**
* Watch the Rust sources, rebuild the native addon, then refresh a target.
*
* There is no hot reload for the Rust half and there cannot be: `require()` of
* a `.node` file calls `process.dlopen`, Node has no matching unload, and all
* the live state (the GPUI platform, GPU device, open window, UI thread, and
* selection registry) stays inside the loaded library. So the loop rebuilds
* and restarts the process instead. An incremental rebuild is a few seconds.
*
* bun scripts/dev.ts # rebuild, then re-run the showcase
* # screenshot test (default)
* bun scripts/dev.ts --shots diff # only tests matching "diff"
* bun scripts/dev.ts --app native-text # rebuild, then restart an example app
*
* Screenshot mode is the better default: the PNGs in
* `packages/react/screenshots/` can be opened in Preview.app, which reloads on
* write, and unlike a live window they can also be read by an agent.
*/
import { spawn, type ChildProcess } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const NATIVE_SRC = path.join(ROOT, "packages", "native", "src")
const SHOTS = path.join(ROOT, "packages", "react", "screenshots")
/** Coalesce the burst of events an editor emits when it saves a file. */
const DEBOUNCE_MS = 120
type Mode =
| { kind: "shots"; pattern: string }
| { kind: "app"; script: string }
function parseArgs(argv: string[]): Mode {
const appIx = argv.indexOf("--app")
if (appIx !== -1) {
const script = argv[appIx + 1]
if (!script) {
fail("--app needs an example name, e.g. --app native-text")
}
return { kind: "app", script }
}
const shotsIx = argv.indexOf("--shots")
return { kind: "shots", pattern: (shotsIx !== -1 && argv[shotsIx + 1]) || "showcase" }
}
function fail(message: string): never {
console.error(`dev: ${message}`)
process.exit(1)
}
function log(message: string) {
const now = new Date().toTimeString().slice(0, 8)
console.log(`[${now}] ${message}`)
}
/** Run a command to completion. Resolves with the exit code, never rejects. */
function run(command: string, args: string[], cwd: string): Promise<number> {
return new Promise((resolve) => {
const child = spawn(command, args, { cwd, stdio: "inherit" })
child.on("exit", (code) => resolve(code ?? 1))
child.on("error", (error) => {
console.error(error.message)
resolve(1)
})
})
}
async function main() {
const mode = parseArgs(process.argv.slice(2))
if (!fs.existsSync(NATIVE_SRC)) {
fail(`no Rust sources at ${NATIVE_SRC}`)
}
fs.mkdirSync(SHOTS, { recursive: true })
let app: ChildProcess | null = null
// Serializes builds. Without it a burst of saves starts overlapping cargo
// runs, which then block on cargo's own lock and report confusing timings.
let running = false
let queued = false
const startApp = (script: string) => {
app?.kill("SIGTERM")
app = spawn("bun", ["run", script], {
cwd: path.join(ROOT, "examples"),
stdio: "inherit",
})
app.on("error", (error) => console.error(error.message))
}
const cycle = async () => {
if (running) {
queued = true
return
}
running = true
try {
const started = Date.now()
log("building native…")
const built = await run("bun", ["run", "build:debug"], path.join(ROOT, "packages", "native"))
if (built !== 0) {
log("build failed, waiting for the next change")
return
}
const seconds = ((Date.now() - started) / 1000).toFixed(1)
log(`built in ${seconds}s`)
if (mode.kind === "app") {
log(`restarting examples/${mode.script}`)
startApp(mode.script)
} else {
log(`rendering "${mode.pattern}" screenshots`)
await run("bun", ["run", "test", mode.pattern], path.join(ROOT, "packages", "react"))
log(`screenshots in ${path.relative(ROOT, SHOTS)}`)
}
} finally {
running = false
if (queued) {
queued = false
void cycle()
}
}
}
let timer: NodeJS.Timeout | null = null
fs.watch(NATIVE_SRC, { recursive: true }, (_event, filename) => {
if (!filename || !filename.endsWith(".rs")) return
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
log(`changed: ${filename}`)
void cycle()
}, DEBOUNCE_MS)
})
const stop = () => {
app?.kill("SIGTERM")
process.exit(0)
}
process.on("SIGINT", stop)
process.on("SIGTERM", stop)
log(`watching ${path.relative(ROOT, NATIVE_SRC)}`)
if (mode.kind === "shots") {
log(`open ${path.relative(ROOT, path.join(SHOTS, `${mode.pattern}.png`))} in Preview.app`)
}
await cycle()
}
main().catch((error) => {
console.error(error)
process.exit(1)
})