@blender-ui/native 0.1.0 → 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,13 +1,19 @@
1
1
  # @blender-ui/native
2
2
 
3
- Blender's UI toolkit from JavaScript: native windows drawn on the CPU or with
4
- Direct3D 11, HTML elements, CSS and Tailwind classes, and signals. It runs on
5
- Node.js and Bun through a native addon (napi-rs), with no browser and no
6
- Electron. For React, see
7
- [`@blender-ui/react`](https://www.npmjs.com/package/@blender-ui/react).
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.
8
14
 
9
15
  ```sh
10
- npm install @blender-ui/native
16
+ npm install @blender-ui/native # or: bun add @blender-ui/native
11
17
  ```
12
18
 
13
19
  ```js
@@ -30,40 +36,91 @@ win.body.appendChild(button)
30
36
  ## API
31
37
 
32
38
  - **A DOM-like tree:** `createElement`, `createTextNode`, `appendChild`,
33
- `insertBefore`, `removeChild`, `setAttribute`, `className` (with Tailwind
34
- classes), `style` (a string or an object), `textContent`.
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.
35
43
  - **Events:** `addEventListener` for `click`, `mousedown`, `mouseup`,
36
44
  `mousemove`, `mouseenter`, `mouseleave`, `wheel`, `keydown`, `keyup`, `input`
37
- and `change`. They bubble through the tree, and `stopPropagation()` works.
38
- They arrive after BUI has handled them, so `preventDefault()` only marks the
39
- event.
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.
40
49
  - **Frames and measurement:** `win.requestAnimationFrame(cb)` is paced by the
41
50
  display. `el.getBoundingClientRect()` and `win.screenshot()` return
42
51
  promises.
43
52
  - **Signals:** `signal`, `memo`, `effect`, `batch`, `untrack`,
44
- `startTransition`. Pass a signal to `setAttribute`, `style`, `textContent`
45
- or `createTextNode`, and its changes update that node alone. The signals are
46
- also available without the addon: `require('@blender-ui/native/signals')`.
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')`.
47
60
  - **Tests:** `new App({ headless: true })` renders off-screen, and
48
61
  `win.click(x, y)` simulates input.
49
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
+
50
88
  ## How it's fast
51
89
 
52
90
  - **Its own UI thread:** BUI renders on a thread of its own, so it never
53
- blocks Node and an idle window uses no CPU.
54
- - **Only changes cross:** each tick, a window sends one batch of operations
55
- (an `Int32Array` and a string table) to the UI thread.
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.
56
94
  - **Styles parsed once:** a `style` value is parsed when it's set, not every
57
95
  frame.
58
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
+
59
116
  ## Requirements
60
117
 
61
- - **Prebuilt for Windows x64.** On other platforms, build from a clone of the
118
+ - **Prebuilt for Windows x64.** Elsewhere, build from a clone of the
62
119
  [repository](https://github.com/ppmpreetham/BUI) with `npm run build` in
63
- `bindings/node`. That needs Rust, CMake and a C compiler; native windows
64
- there are not supported yet, so use `headless: true`.
120
+ `bindings/node`. You need Rust, CMake and a C compiler, and native windows
121
+ aren't supported there yet, so use `headless: true`.
65
122
  - **Runtimes:** Node.js 18 or newer, or Bun.
66
123
 
67
- Guide, benchmarks and internals:
68
- [docs/javascript.md](https://github.com/ppmpreetham/BUI/blob/main/docs/javascript.md).
69
- License: GPL-2.0-or-later.
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.js CHANGED
@@ -10,21 +10,26 @@
10
10
  // SPDX-License-Identifier: GPL-2.0-or-later
11
11
  'use strict'
12
12
 
13
- const path = require('node:path')
14
-
15
- const addon = process.env.BUI_NODE_ADDON ||
16
- path.join(__dirname, `blenderui.${process.platform}-${process.arch}.node`)
17
- let native
18
- try {
19
- native = require(addon)
20
- } catch (err) {
21
- throw new Error(`@blender-ui/native: no native addon for ${process.platform}-${process.arch} (${addon}). ` +
22
- 'Prebuilt binaries are for Windows x64; elsewhere, build it from a clone of ' +
23
- 'https://github.com/ppmpreetham/BUI with `npm run build` in bindings/node (needs Rust, CMake and a C compiler).',
24
- { cause: err })
13
+ // The native layer: the QuickJS runtime's built-in one (bui-runtime, apps
14
+ // made by `bui build`), or the napi addon (Node.js, Bun).
15
+ const native = globalThis.__BUI_NATIVE__ ?? loadAddon()
16
+
17
+ function loadAddon () {
18
+ const env = process.env.BUI_NODE_ADDON
19
+ try {
20
+ // a literal path where a prebuilt binary exists: `bun build --compile` (and
21
+ // other bundlers) can then embed the addon in the executable
22
+ if (!env && process.platform === 'win32' && process.arch === 'x64') return require('./blenderui.win32-x64.node')
23
+ return require(env || require('node:path').join(__dirname, `blenderui.${process.platform}-${process.arch}.node`))
24
+ } catch (err) {
25
+ throw new Error(`@blender-ui/native: no native addon for ${process.platform}-${process.arch}. ` +
26
+ 'Prebuilt binaries are for Windows x64; elsewhere, build it from a clone of ' +
27
+ 'https://github.com/ppmpreetham/BUI with `npm run build` in bindings/node (needs Rust, CMake and a C compiler).',
28
+ { cause: err })
29
+ }
25
30
  }
26
31
  const signals = require('./signals')
27
- const { isSignal, effect, unowned } = signals
32
+ const { isSignal, effect, unowned, memo } = signals
28
33
 
29
34
  // Keep in sync with bindings/rust/blenderui-node/src/lib.rs.
30
35
  const OP = { CREATE: 0, TEXT: 1, SET_ATTR: 2, REMOVE_ATTR: 3, APPEND: 4, INSERT: 5, REMOVE: 6, SET_TEXT: 7, LISTEN: 8, FREE: 9 }
@@ -246,7 +251,13 @@ class Element extends Node {
246
251
  set className (v) { this.setAttribute('class', v) }
247
252
  get style () { return this._attrs.get('style') ?? '' }
248
253
  /** A CSS declaration string, or an object (`{ backgroundColor: 'red', width: 40 }`). */
249
- set style (v) { this.setAttribute('style', typeof v === 'string' || isSignal(v) ? v : styleText(v)) }
254
+ set style (v) {
255
+ if (v == null) return this.removeAttribute('style')
256
+ if (typeof v === 'string' || isSignal(v)) return this.setAttribute('style', v)
257
+ // an object: plain values, or some signals (`{ color: colorSignal }`), then bound
258
+ for (const k in v) if (isSignal(v[k])) return this.setAttribute('style', memo(() => styleText(v)))
259
+ this.setAttribute('style', styleText(v))
260
+ }
250
261
  get textContent () { return super.textContent }
251
262
  set textContent (t) {
252
263
  for (const c of [...this.childNodes]) { this.removeChild(c); c.destroy() }
@@ -310,7 +321,8 @@ const UNITLESS = new Set(['opacity', 'z-index', 'flex', 'flex-grow', 'flex-shrin
310
321
  function styleText (o) {
311
322
  let s = ''
312
323
  for (const k in o) {
313
- const v = o[k]
324
+ const raw = o[k]
325
+ const v = isSignal(raw) ? raw.value : raw // read: a memo around this follows it
314
326
  if (v == null || v === false || v === '') continue
315
327
  const prop = k.startsWith('--') ? k : k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
316
328
  s += `${prop}: ${typeof v === 'number' && v !== 0 && !UNITLESS.has(prop) ? v + 'px' : v}; `
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@blender-ui/native",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Blender's UI from JavaScript: native windows (CPU or Direct3D 11), HTML elements, CSS and Tailwind classes, signals. Node.js and Bun, through napi-rs.",
5
5
  "main": "index.js",
6
6
  "types": "index.d.ts",
7
+ "bin": {
8
+ "bui": "cli.js"
9
+ },
7
10
  "exports": {
8
11
  ".": {
9
12
  "types": "./index.d.ts",
@@ -16,7 +19,9 @@
16
19
  "index.js",
17
20
  "signals.js",
18
21
  "index.d.ts",
22
+ "cli.js",
19
23
  "blenderui.*.node",
24
+ "bui-runtime.*",
20
25
  "README.md",
21
26
  "LICENSE"
22
27
  ],