@blender-ui/native 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,126 @@
1
- # Temporary Holding Version
1
+ # @blender-ui/native
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm](https://img.shields.io/npm/v/@blender-ui/native)](https://www.npmjs.com/package/@blender-ui/native)
4
+ [![license](https://img.shields.io/npm/l/@blender-ui/native)](https://github.com/ppmpreetham/BUI/blob/main/LICENSE)
5
+
6
+ [BUI](https://github.com/ppmpreetham/BUI), Blender's UI toolkit, from
7
+ JavaScript. It gives you native windows drawn on the CPU or with Direct3D 11,
8
+ HTML elements, CSS and Tailwind classes, and signals. It runs on Node.js and
9
+ Bun through a napi-rs addon, and `bui build` turns an app into a single
10
+ executable of about 5 MB. There's no browser and no webview.
11
+
12
+ For React, use [`@blender-ui/react`](https://www.npmjs.com/package/@blender-ui/react),
13
+ which is built on this package.
14
+
15
+ ```sh
16
+ npm install @blender-ui/native # or: bun add @blender-ui/native
17
+ ```
18
+
19
+ ```js
20
+ const { App, signal } = require('@blender-ui/native')
21
+
22
+ const app = new App({ gpu: true })
23
+ const win = app.window({ title: 'Hello', width: 360, height: 200 })
24
+ win.body.className = 'm-0 p-6 flex flex-col gap-3 bg-[#2b2b2b] text-[#e6e6e6]'
25
+
26
+ const clicks = signal(0)
27
+ const label = win.createElement('span')
28
+ label.textContent = clicks // follows the signal
29
+ const button = win.createElement('button')
30
+ button.textContent = 'Add one'
31
+ button.addEventListener('click', () => clicks.value++)
32
+ win.body.appendChild(label)
33
+ win.body.appendChild(button)
34
+ ```
35
+
36
+ ## API
37
+
38
+ - **A DOM-like tree:** `createElement`, `createTextNode`, `appendChild`,
39
+ `insertBefore`, `removeChild`, `setAttribute`, `className` (Tailwind
40
+ classes included), `style` (a string or an object), `textContent`.
41
+ - Moving nodes and the error cases behave as in the DOM.
42
+ - `destroy()` frees a removed subtree natively.
43
+ - **Events:** `addEventListener` for `click`, `mousedown`, `mouseup`,
44
+ `mousemove`, `mouseenter`, `mouseleave`, `wheel`, `keydown`, `keyup`, `input`
45
+ and `change`.
46
+ - They bubble through the tree, and `stopPropagation()` works.
47
+ - They arrive after BUI has handled them, so `preventDefault()` only marks
48
+ the event.
49
+ - **Frames and measurement:** `win.requestAnimationFrame(cb)` is paced by the
50
+ display. `el.getBoundingClientRect()` and `win.screenshot()` return
51
+ promises.
52
+ - **Signals:** `signal`, `memo`, `effect`, `batch`, `untrack`,
53
+ `startTransition`.
54
+ - Pass a signal to `setAttribute`, `style` (or inside a style object),
55
+ `textContent` or `createTextNode`, and its changes update that node
56
+ alone.
57
+ - Memos and effects track what they read, are glitch-free, and need no
58
+ dependency arrays.
59
+ - Without the addon: `require('@blender-ui/native/signals')`.
60
+ - **Tests:** `new App({ headless: true })` renders off-screen, and
61
+ `win.click(x, y)` simulates input.
62
+
63
+ ## Standalone apps
64
+
65
+ ```sh
66
+ npx bui build app.js # app.exe: BUI + embedded QuickJS, ~5 MB
67
+ npx bui build app.js --bun # app.exe: Bun, with Node's APIs and a JIT, ~115 MB
68
+ npx bui run app.js # run on the QuickJS runtime, no build
69
+ ```
70
+
71
+ Options:
72
+
73
+ - `-o <file>`: the output file.
74
+ - `--console`: keep a console window for logs (Windows builds open none).
75
+ - `--dev`: React's development build.
76
+
77
+ Bundling uses [Bun](https://bun.sh), so it must be installed.
78
+
79
+ **How the default build works.** It appends your bundled app to `bui-runtime`,
80
+ BUI with the QuickJS engine embedded. That takes under a second and compiles
81
+ nothing.
82
+
83
+ - **It's fast to start:** the result reaches its first frame in about 70 ms
84
+ and uses 19 MB of RAM there.
85
+ - **It's limited:** no Node modules (`fs` and the like) and no JIT. Keep
86
+ frequent updates on signals, or use `--bun`.
87
+
88
+ ## How it's fast
89
+
90
+ - **Its own UI thread:** BUI renders on a thread of its own, so it never
91
+ blocks JavaScript, and an idle window uses no CPU.
92
+ - **Only changes cross:** each tick, a window sends one batch of operations,
93
+ an `Int32Array` and a string table.
94
+ - **Styles parsed once:** a `style` value is parsed when it's set, not every
95
+ frame.
96
+
97
+ ## Benchmarks
98
+
99
+ Ryzen 7 5800U laptop, Windows 11, GPU renderer. Method:
100
+ [guide](https://github.com/ppmpreetham/BUI/blob/main/docs/javascript.md#performance).
101
+
102
+ | Startup (small app, quits at first frame) | First frame | Launch to exit | RAM | Size |
103
+ |---|---:|---:|---:|---:|
104
+ | **`bui build`** (QuickJS) | **68 ms** | **104 ms** | **19 MB** | **4.9 MB** |
105
+ | `bui build --bun` | 79 ms | 147 ms | 35 MB | 114 MB |
106
+ | `bun app.js` | 80 ms | 152 ms | 35 MB | |
107
+ | `node app.js` | 108 ms | 187 ms | 45 MB | |
108
+
109
+ | 3,000 cells, one changing per frame | FPS | CPU per frame | RAM |
110
+ |---|---:|---:|---:|
111
+ | Bun | 58.8 | 13.1 ms | 80 MB |
112
+ | Node.js | 55.8 | 14.4 ms | 88 MB |
113
+ | QuickJS (`bui build`) | 59.1 | 16.3 ms | 56 MB |
114
+ | *Rust, for reference* | 59.6 | 12.0 ms | 43 MB |
115
+
116
+ ## Requirements
117
+
118
+ - **Prebuilt for Windows x64.** Elsewhere, build from a clone of the
119
+ [repository](https://github.com/ppmpreetham/BUI) with `npm run build` in
120
+ `bindings/node`. You need Rust, CMake and a C compiler, and native windows
121
+ aren't supported there yet, so use `headless: true`.
122
+ - **Runtimes:** Node.js 18 or newer, or Bun.
123
+
124
+ Guide: [docs/javascript.md](https://github.com/ppmpreetham/BUI/blob/main/docs/javascript.md) ·
125
+ Source: [github.com/ppmpreetham/BUI](https://github.com/ppmpreetham/BUI) ·
126
+ License: GPL-2.0-or-later
Binary file
Binary file
package/cli.js ADDED
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env node
2
+ // bui: build standalone apps from a JavaScript / JSX entry.
3
+ //
4
+ // bui build app.jsx [-o app.exe] a small executable: BUI + QuickJS (~5 MB)
5
+ // bui build app.jsx --bun a Bun executable (full Node APIs, ~120 MB)
6
+ // bui run app.jsx [args] run on the small runtime (no executable)
7
+ //
8
+ // Options:
9
+ // -o, --out <file> output file (default: the entry's name, .exe on Windows)
10
+ // --bun compile with `bun build --compile` instead of QuickJS
11
+ // --console keep a console window (Windows; default: none, for apps)
12
+ // --dev React's development build (default: production)
13
+ //
14
+ // Bundling uses Bun (https://bun.sh), which must be installed.
15
+ // SPDX-License-Identifier: GPL-2.0-or-later
16
+ 'use strict'
17
+ const fs = require('node:fs')
18
+ const os = require('node:os')
19
+ const path = require('node:path')
20
+ const { spawnSync } = require('node:child_process')
21
+
22
+ const MAGIC = Buffer.from('BUIAPP01')
23
+ const TAG = `${process.platform}-${process.arch}`
24
+ const EXE = process.platform === 'win32' ? '.exe' : ''
25
+
26
+ function fail (msg) {
27
+ console.error(`bui: ${msg}`)
28
+ process.exit(1)
29
+ }
30
+
31
+ function parse (argv) {
32
+ const o = { cmd: argv[0], entry: null, out: null, bun: false, console: false, dev: false, rest: [] }
33
+ for (let i = 1; i < argv.length; i++) {
34
+ const a = argv[i]
35
+ if (o.cmd === 'run' && o.entry !== null) o.rest.push(a)
36
+ else if (a === '-o' || a === '--out') o.out = argv[++i]
37
+ else if (a === '--bun') o.bun = true
38
+ else if (a === '--console') o.console = true
39
+ else if (a === '--dev') o.dev = true
40
+ else if (a.startsWith('-')) fail(`unknown option ${a}`)
41
+ else o.entry = a
42
+ }
43
+ return o
44
+ }
45
+
46
+ /** The QuickJS runtime shipped in this package (or the workspace build). */
47
+ function runtimePath () {
48
+ const shipped = path.join(__dirname, `bui-runtime.${TAG}${EXE}`)
49
+ if (fs.existsSync(shipped)) return shipped
50
+ const built = path.join(__dirname, '../rust/target/release', `bui-runtime${EXE}`)
51
+ if (fs.existsSync(built)) return built
52
+ fail(`no bui-runtime for ${TAG}: prebuilt for Windows x64; run \`npm run build\` in bindings/node to build it`)
53
+ }
54
+
55
+ /** Bun's executable: this process when running on Bun; otherwise asked once
56
+ * (npm installs it behind a shell shim on Windows), then run without a shell
57
+ * so arguments keep their quotes. */
58
+ let bunPath = null
59
+ function findBun () {
60
+ if (bunPath !== null) return bunPath
61
+ if (typeof Bun !== 'undefined') return (bunPath = process.execPath)
62
+ const r = spawnSync('bun', ['-e', 'process.stdout.write(process.execPath)'],
63
+ { encoding: 'utf8', shell: process.platform === 'win32' })
64
+ if (r.status !== 0 || !r.stdout) fail('bundling needs Bun: https://bun.sh')
65
+ return (bunPath = r.stdout.trim())
66
+ }
67
+
68
+ function bun (args, opts = {}) {
69
+ const r = spawnSync(findBun(), args, { stdio: opts.quiet ? 'pipe' : 'inherit', encoding: 'utf8' })
70
+ if (r.status !== 0) fail(`bun ${args[0]} failed${r.stderr ? `:
71
+ ${r.stderr}` : ''}`)
72
+ return r
73
+ }
74
+
75
+ /** One script for QuickJS: the app and its packages, React in production mode. */
76
+ function bundle (entry, dev) {
77
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bui-'))
78
+ // an ES module importing the entry: Bun's IIFE output runs a CommonJS
79
+ // entry only when something imports it
80
+ const wrapper = path.join(dir, 'entry.mjs')
81
+ fs.writeFileSync(wrapper, `import ${JSON.stringify(path.resolve(entry).split(path.sep).join('/'))}\n`)
82
+ const out = path.join(dir, 'app.js')
83
+ bun(['build', wrapper, '--target=browser', '--format=iife', '--minify',
84
+ `--define=process.env.NODE_ENV="${dev ? 'development' : 'production'}"`,
85
+ '--external=node:*', '--external=*.node', `--outfile=${out}`], { quiet: true })
86
+ const js = fs.readFileSync(out)
87
+ fs.rmSync(dir, { recursive: true, force: true })
88
+ return js
89
+ }
90
+
91
+ /** Windows: mark the executable as a windowed app (no console window). */
92
+ function setGuiSubsystem (buf) {
93
+ if (buf.readUInt16LE(0) !== 0x5a4d) return
94
+ const pe = buf.readUInt32LE(0x3c)
95
+ if (buf.readUInt32LE(pe) !== 0x4550) return
96
+ buf.writeUInt16LE(2, pe + 24 + 68) // IMAGE_SUBSYSTEM_WINDOWS_GUI
97
+ }
98
+
99
+ function build (o) {
100
+ const name = path.basename(o.entry).replace(/\.[^.]+$/, '')
101
+ const out = path.resolve(o.out ?? `${name}${EXE}`)
102
+ const t0 = Date.now()
103
+ if (o.bun) {
104
+ const args = ['build', '--compile', '--minify', o.entry, `--outfile=${out}`,
105
+ `--define=process.env.NODE_ENV="${o.dev ? 'development' : 'production'}"`]
106
+ if (process.platform === 'win32' && !o.console) args.push('--windows-hide-console')
107
+ bun(args)
108
+ } else {
109
+ const js = bundle(o.entry, o.dev)
110
+ const runtime = fs.readFileSync(runtimePath())
111
+ if (process.platform === 'win32' && !o.console) setGuiSubsystem(runtime)
112
+ const trailer = Buffer.alloc(16)
113
+ MAGIC.copy(trailer, 0)
114
+ trailer.writeBigUInt64LE(BigInt(js.length), 8)
115
+ fs.writeFileSync(out, Buffer.concat([runtime, js, trailer]))
116
+ if (process.platform !== 'win32') fs.chmodSync(out, 0o755)
117
+ }
118
+ const mb = (fs.statSync(out).size / 1048576).toFixed(1)
119
+ console.log(`bui: built ${path.relative(process.cwd(), out)} (${mb} MB, ${o.bun ? 'Bun' : 'QuickJS'}) in ${Date.now() - t0} ms`)
120
+ }
121
+
122
+ function run (o) {
123
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bui-run-'))
124
+ const file = path.join(dir, `${path.basename(o.entry).replace(/\.[^.]+$/, '')}.js`)
125
+ fs.writeFileSync(file, bundle(o.entry, o.dev))
126
+ const r = spawnSync(runtimePath(), [file, ...o.rest], { stdio: 'inherit' })
127
+ fs.rmSync(dir, { recursive: true, force: true })
128
+ process.exit(r.status ?? 1)
129
+ }
130
+
131
+ const o = parse(process.argv.slice(2))
132
+ if (!['build', 'run'].includes(o.cmd) || !o.entry) {
133
+ console.log(fs.readFileSync(__filename, 'utf8').split('\n').slice(1, 14).map((l) => l.replace(/^\/\/ ?/, '')).join('\n'))
134
+ process.exit(o.cmd ? 1 : 0)
135
+ }
136
+ if (!fs.existsSync(o.entry)) fail(`no such file: ${o.entry}`)
137
+ if (o.cmd === 'build') build(o)
138
+ else run(o)
package/index.d.ts ADDED
@@ -0,0 +1,187 @@
1
+ // Types for @blender-ui/native.
2
+ export interface AppOptions {
3
+ /** Draw on the GPU (Direct3D 11). */
4
+ gpu?: boolean
5
+ /** GPU text from glyph outlines (Slug, default) or a per-glyph atlas. */
6
+ slug?: boolean
7
+ /** No native windows: tests, screenshots, CI. */
8
+ headless?: boolean
9
+ /** Quit when the last window closes (default true). */
10
+ quitOnLastWindowClosed?: boolean
11
+ }
12
+
13
+ export interface WindowOptions {
14
+ title?: string
15
+ width?: number
16
+ height?: number
17
+ resizable?: boolean
18
+ gpu?: boolean
19
+ }
20
+
21
+ export type EventType = 'click' | 'mousedown' | 'mouseup' | 'mousemove' | 'mouseenter' | 'mouseleave' |
22
+ 'wheel' | 'keydown' | 'keyup' | 'change' | 'input'
23
+
24
+ export class BuiEvent {
25
+ type: EventType
26
+ target: Element
27
+ currentTarget: Element
28
+ clientX: number
29
+ clientY: number
30
+ x: number
31
+ y: number
32
+ button: number
33
+ detail: number
34
+ deltaY: number
35
+ key?: string
36
+ char?: string
37
+ shiftKey: boolean
38
+ ctrlKey: boolean
39
+ altKey: boolean
40
+ metaKey: boolean
41
+ timeStamp: number
42
+ preventDefault (): void
43
+ stopPropagation (): void
44
+ }
45
+
46
+ export class Node {
47
+ readonly ownerWindow: Window
48
+ readonly id: number
49
+ parentNode: Element | null
50
+ childNodes: Node[]
51
+ readonly firstChild: Node | null
52
+ readonly lastChild: Node | null
53
+ readonly nextSibling: Node | null
54
+ textContent: string
55
+ appendChild<T extends Node> (child: T): T
56
+ insertBefore<T extends Node> (child: T, before: Node | null): T
57
+ removeChild<T extends Node> (child: T): T
58
+ remove (): void
59
+ /** Free the node and its subtree natively (after removing it). */
60
+ destroy (): void
61
+ }
62
+
63
+ export class TextNode extends Node {
64
+ /** A string, or a signal (the text then follows it). */
65
+ data: string | ReadSignal<unknown>
66
+ nodeValue: string
67
+ }
68
+
69
+ export class Element extends Node {
70
+ readonly tagName: string
71
+ readonly localName: string
72
+ className: string
73
+ /** CSS text, or an object such as `{ backgroundColor: 'red', width: 40 }`. */
74
+ style: string | Record<string, string | number | null | undefined> | ReadSignal<string | Record<string, string | number | null | undefined>>
75
+ /** Form controls: the latest value from input / change events. */
76
+ value?: string
77
+ checked?: boolean
78
+ /** A value, or a signal: the attribute then follows it (null / false removes it). */
79
+ setAttribute (name: string, value: unknown): void
80
+ getAttribute (name: string): string | null
81
+ hasAttribute (name: string): boolean
82
+ removeAttribute (name: string): void
83
+ addEventListener (type: EventType, listener: (event: BuiEvent) => void): void
84
+ removeEventListener (type: EventType, listener: (event: BuiEvent) => void): void
85
+ getBoundingClientRect (): Promise<{ x: number, y: number, width: number, height: number } | null>
86
+ }
87
+
88
+ export class Window {
89
+ readonly app: App
90
+ readonly id: number
91
+ readonly body: Element
92
+ closed: boolean
93
+ createElement (tag: string): Element
94
+ createTextNode (text: string | ReadSignal<unknown>): TextNode
95
+ /** Send pending changes now (they are otherwise sent once per tick). */
96
+ flush (): void
97
+ /** Run `cb` before the next frame, paced by the display. */
98
+ requestAnimationFrame (cb: (time: number) => void): number
99
+ cancelAnimationFrame (id: number): void
100
+ screenshot (): Promise<{ width: number, height: number, data: Buffer }>
101
+ click (x: number, y: number): void
102
+ close (): void
103
+ on (event: 'close', listener: () => void): this
104
+ }
105
+
106
+ export class App {
107
+ constructor (options?: AppOptions)
108
+ readonly headless: boolean
109
+ readonly windows: Map<number, Window>
110
+ quitOnLastWindowClosed: boolean
111
+ window (options?: WindowOptions): Window
112
+ setSlug (on: boolean): void
113
+ quit (): void
114
+ }
115
+
116
+ export function styleText (style: Record<string, string | number | null | undefined>): string
117
+
118
+ // Signals: BUI's hooks (the Rust crate's Signal / Memo / Effect).
119
+ // Bind one to an attribute, `style` or text and that node alone updates.
120
+
121
+ export interface ReadSignal<T> {
122
+ /** Read, subscribing the running memo / effect. */
123
+ readonly value: T
124
+ get (): T
125
+ /** Read without subscribing. */
126
+ peek (): T
127
+ with<R> (fn: (value: T) => R): R
128
+ /** Call `fn` now and on every change; returns the unsubscribe function. */
129
+ subscribe (fn: (value: T) => void): () => void
130
+ /** `count + 1`, `count > 5` read the value (`===` and truthiness don't: use `.value`). */
131
+ valueOf (): T
132
+ }
133
+
134
+ export class Signal<T> implements ReadSignal<T> {
135
+ constructor (value: T, options?: { equals?: (a: T, b: T) => boolean })
136
+ value: T
137
+ get (): T
138
+ peek (): T
139
+ set (value: T): void
140
+ update (fn: (value: T) => T): void
141
+ with<R> (fn: (value: T) => R): R
142
+ subscribe (fn: (value: T) => void): () => void
143
+ valueOf (): T
144
+ }
145
+
146
+ export class Memo<T> implements ReadSignal<T> {
147
+ readonly value: T
148
+ get (): T
149
+ peek (): T
150
+ with<R> (fn: (value: T) => R): R
151
+ subscribe (fn: (value: T) => void): () => void
152
+ valueOf (): T
153
+ }
154
+
155
+ export function signal<T> (value: T, options?: { equals?: (a: T, b: T) => boolean }): Signal<T>
156
+ /** A lazily derived, cached value, recomputed when the signals it read change. */
157
+ export function memo<T> (fn: () => T, options?: { equals?: (a: T, b: T) => boolean }): Memo<T>
158
+ export const computed: typeof memo
159
+ /** Runs `fn` now and when what it read changes; `fn` may return a cleanup. Returns `dispose`. */
160
+ export function effect (fn: () => void | (() => void)): () => void
161
+ /** Several writes, one update. */
162
+ export function batch<R> (fn: () => R): R
163
+ export function untrack<R> (fn: () => R): R
164
+ /** Leptos / Solid style getter and setter. */
165
+ export function createSignal<T> (value: T): [() => T, (value: T | ((prev: T) => T)) => void]
166
+ export function isSignal (x: unknown): x is ReadSignal<unknown>
167
+ /** Writes in `fn` update what they affect after urgent work, in slices. Settles
168
+ * when done; rejects with the error of an async `fn`. */
169
+ export function startTransition (fn: () => void | Promise<void>): Promise<void>
170
+ /** Run `fn` at urgent priority, even inside a transition. */
171
+ export function urgent<R> (fn: () => R): R
172
+
173
+ /** A ref whose `.current` effects and memos can follow (reading it while a
174
+ * React component renders doesn't subscribe). */
175
+ export class Ref<T> extends Signal<T> { current: T }
176
+ export function ref<T> (value: T): Ref<T>
177
+ /** Run `fn` outside any owning effect: effects it creates live until disposed. */
178
+ export function unowned<R> (fn: () => R): R
179
+
180
+ /** The addon (internal), plus counters for benchmarks. */
181
+ export const native: {
182
+ /** CPU cycles of every thread of this process (Windows), or null. */
183
+ processCpuCycles (): number | null
184
+ /** The x86-64 time-stamp counter, or null. */
185
+ timestampCounter (): number | null
186
+ [key: string]: unknown
187
+ }