@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/index.js ADDED
@@ -0,0 +1,553 @@
1
+ // @blender-ui/native: Blender's UI from JavaScript.
2
+ //
3
+ // A DOM-like retained tree (createElement, setAttribute, appendChild,
4
+ // addEventListener) rendered by BUI in native windows: HTML elements, CSS,
5
+ // Tailwind classes. BUI runs on its own thread; each window collects the
6
+ // tree changes of a tick and sends them in one call (an Int32Array of ops
7
+ // and a string table), so only what changed crosses into Rust. Events come
8
+ // back asynchronously.
9
+ //
10
+ // SPDX-License-Identifier: GPL-2.0-or-later
11
+ 'use strict'
12
+
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
+ }
30
+ }
31
+ const signals = require('./signals')
32
+ const { isSignal, effect, unowned, memo } = signals
33
+
34
+ // Keep in sync with bindings/rust/blenderui-node/src/lib.rs.
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 }
36
+ const EVENT_BITS = {
37
+ click: 1, mousedown: 2, mouseup: 4, mousemove: 8, mouseenter: 16, mouseleave: 16,
38
+ wheel: 32, keydown: 64, keyup: 128, change: 256, input: 512,
39
+ }
40
+ const BUTTONS = ['left', 'middle', 'right']
41
+ // DOM: these don't bubble
42
+ const NO_BUBBLE = new Set(['mouseenter', 'mouseleave'])
43
+
44
+ /** The event object handlers receive (a subset of the DOM's). */
45
+ class BuiEvent {
46
+ constructor (raw, target) {
47
+ this.type = raw.kind
48
+ this.bubbles = !NO_BUBBLE.has(raw.kind)
49
+ // the deepest element listening for this type (BUI reports no deeper target)
50
+ this.target = target
51
+ this.currentTarget = target
52
+ this.clientX = this.x = raw.x
53
+ this.clientY = this.y = raw.y
54
+ this.button = raw.button
55
+ this.detail = raw.clickCount
56
+ this.deltaY = -raw.delta * 100 // DOM: positive scrolls down
57
+ this.key = raw.key ?? undefined
58
+ this.char = raw.keyChar ?? undefined
59
+ this.shiftKey = raw.shift
60
+ this.ctrlKey = raw.ctrl
61
+ this.altKey = raw.alt
62
+ this.metaKey = false
63
+ this.timeStamp = performance.now()
64
+ this.defaultPrevented = false
65
+ this.propagationStopped = false
66
+ this.immediatePropagationStopped = false
67
+ }
68
+
69
+ /** Events reach JavaScript after BUI handled them: this marks the event only. */
70
+ preventDefault () { this.defaultPrevented = true }
71
+ stopPropagation () { this.propagationStopped = true }
72
+ stopImmediatePropagation () { this.propagationStopped = this.immediatePropagationStopped = true }
73
+ }
74
+
75
+ class Node {
76
+ constructor (win, id) {
77
+ this.ownerWindow = win
78
+ this.id = id
79
+ this.parentNode = null
80
+ this._bindings = null
81
+ this.childNodes = []
82
+ }
83
+
84
+ get firstChild () { return this.childNodes[0] ?? null }
85
+ get lastChild () { return this.childNodes[this.childNodes.length - 1] ?? null }
86
+ get nextSibling () {
87
+ const p = this.parentNode
88
+ return p ? p.childNodes[p.childNodes.indexOf(this) + 1] ?? null : null
89
+ }
90
+
91
+ appendChild (child) {
92
+ this._adopt(child)
93
+ child._detach()
94
+ this.childNodes.push(child)
95
+ child.parentNode = this
96
+ this.ownerWindow._op(OP.APPEND, this.id, child.id)
97
+ return child
98
+ }
99
+
100
+ insertBefore (child, before) {
101
+ if (!before) return this.appendChild(child)
102
+ if (before.parentNode !== this) throw new Error('blenderui: insertBefore: the reference node is not a child of this node')
103
+ if (child === before) return child
104
+ this._adopt(child)
105
+ child._detach()
106
+ this.childNodes.splice(this.childNodes.indexOf(before), 0, child)
107
+ child.parentNode = this
108
+ this.ownerWindow._op(OP.INSERT, this.id, child.id, before.id)
109
+ return child
110
+ }
111
+
112
+ removeChild (child) {
113
+ const i = this.childNodes.indexOf(child)
114
+ if (i >= 0) {
115
+ this.childNodes.splice(i, 1)
116
+ child.parentNode = null
117
+ this.ownerWindow._op(OP.REMOVE, this.id, child.id)
118
+ }
119
+ return child
120
+ }
121
+
122
+ remove () { this.parentNode?.removeChild(this) }
123
+
124
+ _detach () {
125
+ const p = this.parentNode
126
+ if (p) {
127
+ p.childNodes.splice(p.childNodes.indexOf(this), 1)
128
+ this.parentNode = null
129
+ if (p !== this._nextParent) this.ownerWindow._op(OP.REMOVE, p.id, this.id)
130
+ }
131
+ }
132
+
133
+ /** Checks before `child` moves under this node (as the DOM does). */
134
+ _adopt (child) {
135
+ if (child.ownerWindow !== this.ownerWindow) throw new Error('blenderui: the node belongs to another window')
136
+ for (let n = this; n; n = n.parentNode) {
137
+ if (n === child) throw new Error('blenderui: a node cannot be inserted into itself or its descendants')
138
+ }
139
+ child._nextParent = this // same parent: APPEND / INSERT reorders it natively
140
+ }
141
+
142
+ /** Keep `key` (an attribute, or the text) following `source`: each change
143
+ * updates this node alone. */
144
+ _bind (key, source, apply) {
145
+ this._unbind(key)
146
+ // unowned: a binding made inside an effect lives as long as the node, not that effect's run
147
+ ;(this._bindings ??= new Map()).set(key, unowned(() => effect(() => { apply(source.value) })))
148
+ }
149
+
150
+ _unbind (key) {
151
+ const dispose = this._bindings?.get(key)
152
+ if (dispose) {
153
+ dispose()
154
+ this._bindings.delete(key)
155
+ }
156
+ }
157
+
158
+ _unbindAll () {
159
+ if (this._bindings) {
160
+ for (const dispose of this._bindings.values()) dispose()
161
+ this._bindings = null
162
+ }
163
+ }
164
+
165
+ /** Free this node and its subtree on the native side (after removing it). */
166
+ destroy () {
167
+ for (const c of this.childNodes) c.destroy()
168
+ this.ownerWindow._free(this)
169
+ }
170
+
171
+ get textContent () { return this.childNodes.map(c => c.textContent).join('') }
172
+ }
173
+
174
+ class TextNode extends Node {
175
+ constructor (win, id, text) {
176
+ super(win, id)
177
+ this._text = text
178
+ }
179
+
180
+ get nodeType () { return 3 }
181
+ get data () { return this._text }
182
+ /** A string, or a signal (the text then follows it). */
183
+ set data (t) {
184
+ if (isSignal(t)) return this._bind('data', t, (v) => this._setText(v))
185
+ this._unbind('data')
186
+ this._setText(t)
187
+ }
188
+
189
+ _setText (t) {
190
+ t = t == null ? '' : String(t)
191
+ if (t !== this._text) {
192
+ this._text = t
193
+ this.ownerWindow._op(OP.SET_TEXT, this.id, this.ownerWindow._s(t))
194
+ }
195
+ }
196
+
197
+ get nodeValue () { return this._text }
198
+ set nodeValue (t) { this.data = t }
199
+ get textContent () { return this._text }
200
+ set textContent (t) { this.data = t }
201
+ }
202
+
203
+ class Element extends Node {
204
+ constructor (win, id, tag) {
205
+ super(win, id)
206
+ this.tagName = tag.toUpperCase()
207
+ this.localName = tag
208
+ this._attrs = new Map()
209
+ this._listeners = new Map()
210
+ this._bits = 0
211
+ // form controls: the latest value from input / change events
212
+ this.value = undefined
213
+ this.checked = undefined
214
+ }
215
+
216
+ get nodeType () { return 1 }
217
+
218
+ /** A value, or a signal: the attribute then follows it (null / false removes it). */
219
+ setAttribute (name, value) {
220
+ if (isSignal(value)) {
221
+ return this._bind(name, value, (v) => {
222
+ if (v == null || v === false) this._removeAttr(name)
223
+ else this._setAttr(name, name === 'style' && typeof v === 'object' ? styleText(v) : v === true ? '' : v)
224
+ })
225
+ }
226
+ this._unbind(name)
227
+ this._setAttr(name, value)
228
+ }
229
+
230
+ _setAttr (name, value) {
231
+ value = String(value)
232
+ if (this._attrs.get(name) === value) return
233
+ this._attrs.set(name, value)
234
+ const w = this.ownerWindow
235
+ w._op(OP.SET_ATTR, this.id, w._s(name), w._s(value))
236
+ }
237
+
238
+ getAttribute (name) { return this._attrs.get(name) ?? null }
239
+ hasAttribute (name) { return this._attrs.has(name) }
240
+
241
+ removeAttribute (name) {
242
+ this._unbind(name)
243
+ this._removeAttr(name)
244
+ }
245
+
246
+ _removeAttr (name) {
247
+ if (this._attrs.delete(name)) this.ownerWindow._op(OP.REMOVE_ATTR, this.id, this.ownerWindow._s(name))
248
+ }
249
+
250
+ get className () { return this._attrs.get('class') ?? '' }
251
+ set className (v) { this.setAttribute('class', v) }
252
+ get style () { return this._attrs.get('style') ?? '' }
253
+ /** A CSS declaration string, or an object (`{ backgroundColor: 'red', width: 40 }`). */
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
+ }
261
+ get textContent () { return super.textContent }
262
+ set textContent (t) {
263
+ for (const c of [...this.childNodes]) { this.removeChild(c); c.destroy() }
264
+ if (t !== '' && t != null) this.appendChild(this.ownerWindow.createTextNode(isSignal(t) ? t : String(t)))
265
+ }
266
+
267
+ addEventListener (type, fn) {
268
+ if (!(type in EVENT_BITS)) throw new Error(`blenderui: unsupported event "${type}"`)
269
+ let set = this._listeners.get(type)
270
+ if (!set) this._listeners.set(type, (set = new Set()))
271
+ set.add(fn)
272
+ this._listen()
273
+ }
274
+
275
+ removeEventListener (type, fn) {
276
+ this._listeners.get(type)?.delete(fn)
277
+ this._listen()
278
+ }
279
+
280
+ _listen () {
281
+ let bits = 0
282
+ for (const [type, set] of this._listeners) if (set.size) bits |= EVENT_BITS[type]
283
+ if (bits !== this._bits) {
284
+ this._bits = bits
285
+ this.ownerWindow._op(OP.LISTEN, this.id, bits)
286
+ }
287
+ }
288
+
289
+ _dispatch (raw) {
290
+ if (raw.kind === 'input' || raw.kind === 'change') {
291
+ this.value = raw.value
292
+ this.checked = raw.checked
293
+ this.valueAsNumber = raw.number
294
+ }
295
+ const e = new BuiEvent(raw, this)
296
+ for (let n = this; n !== null; n = n.parentNode) {
297
+ const set = n._listeners?.get(raw.kind)
298
+ if (set && set.size) {
299
+ e.currentTarget = n
300
+ for (const fn of [...set]) {
301
+ try { fn.call(n, e) } catch (err) { queueMicrotask(() => { throw err }) }
302
+ if (e.immediatePropagationStopped) break
303
+ }
304
+ }
305
+ if (e.propagationStopped || !e.bubbles) break
306
+ }
307
+ e.currentTarget = null
308
+ }
309
+
310
+ /** Layout box `{ x, y, width, height }` in logical pixels (or null). */
311
+ getBoundingClientRect () {
312
+ this.ownerWindow.flush() // the UI thread must see the tree first
313
+ return this.ownerWindow.app.native.bounds(this.ownerWindow.id, this.id)
314
+ }
315
+ }
316
+
317
+ const UNITLESS = new Set(['opacity', 'z-index', 'flex', 'flex-grow', 'flex-shrink', 'order', 'font-weight',
318
+ 'line-height', 'zoom', 'aspect-ratio', 'grid-row', 'grid-column', 'scale'])
319
+
320
+ /** `{ backgroundColor: 'red', width: 40 }` -> `background-color: red; width: 40px` */
321
+ function styleText (o) {
322
+ let s = ''
323
+ for (const k in o) {
324
+ const raw = o[k]
325
+ const v = isSignal(raw) ? raw.value : raw // read: a memo around this follows it
326
+ if (v == null || v === false || v === '') continue
327
+ const prop = k.startsWith('--') ? k : k.replace(/[A-Z]/g, m => '-' + m.toLowerCase())
328
+ s += `${prop}: ${typeof v === 'number' && v !== 0 && !UNITLESS.has(prop) ? v + 'px' : v}; `
329
+ }
330
+ return s
331
+ }
332
+
333
+ class Window {
334
+ constructor (app, id, options = {}) {
335
+ this.app = app
336
+ this.id = id
337
+ this.closed = false
338
+ this._next = 1
339
+ this._nodes = new Map()
340
+ this._ops = []
341
+ this._strings = []
342
+ this._stringIds = new Map()
343
+ this._scheduled = false
344
+ this._onClose = new Set()
345
+ this._frameCallbacks = new Map()
346
+ this._nextFrameId = 1
347
+ this._framePending = false
348
+ this._inFrame = false
349
+ /** The root element: node 0. */
350
+ this.body = new Element(this, 0, 'body')
351
+ this._nodes.set(0, this.body)
352
+ app.native.openWindow(id, {
353
+ title: options.title, width: options.width, height: options.height,
354
+ resizable: options.resizable, gpu: options.gpu,
355
+ })
356
+ }
357
+
358
+ createElement (tag) {
359
+ const id = this._next++
360
+ const el = new Element(this, id, tag)
361
+ this._nodes.set(id, el)
362
+ this._op(OP.CREATE, id, this._s(tag))
363
+ return el
364
+ }
365
+
366
+ /** A text node; `text` may be a signal (the text then follows it). */
367
+ createTextNode (text) {
368
+ const id = this._next++
369
+ const bound = isSignal(text)
370
+ const t = new TextNode(this, id, bound ? String(text.peek() ?? '') : String(text))
371
+ this._nodes.set(id, t)
372
+ this._op(OP.TEXT, id, this._s(t._text))
373
+ if (bound) t.data = text
374
+ return t
375
+ }
376
+
377
+ /** Changes are sent once per tick; `flush()` sends them now. */
378
+ flush () {
379
+ this._scheduled = false
380
+ if (this.closed) {
381
+ this._ops = []
382
+ this._strings = []
383
+ this._stringIds.clear()
384
+ return
385
+ }
386
+ if (!this._ops.length) return
387
+ const ops = Int32Array.from(this._ops)
388
+ const strings = this._strings
389
+ this._ops = []
390
+ this._strings = []
391
+ this._stringIds.clear()
392
+ // while animating, changes render on the next vsync (one rebuild per frame)
393
+ this.app.native.commit(this.id, ops, strings, this._inFrame || this._framePending)
394
+ }
395
+
396
+ /**
397
+ * Run `cb(timestamp)` before the next frame renders, paced by the display
398
+ * (like the browser's). Changes made in it render in that frame.
399
+ */
400
+ requestAnimationFrame (cb) {
401
+ const id = this._nextFrameId++
402
+ this._frameCallbacks.set(id, cb)
403
+ if (!this._framePending) {
404
+ this._framePending = true
405
+ this.flush()
406
+ this.app.native.requestFrame(this.id)
407
+ }
408
+ return id
409
+ }
410
+
411
+ cancelAnimationFrame (id) { this._frameCallbacks.delete(id) }
412
+
413
+ _frame () {
414
+ this._framePending = false
415
+ const cbs = [...this._frameCallbacks.values()]
416
+ this._frameCallbacks.clear()
417
+ this._inFrame = true
418
+ const now = performance.now()
419
+ try {
420
+ for (const cb of cbs) {
421
+ try { cb(now) } catch (err) { queueMicrotask(() => { throw err }) }
422
+ }
423
+ } finally {
424
+ this._inFrame = false
425
+ }
426
+ this.flush()
427
+ }
428
+
429
+ /** Render and read the pixels: `{ width, height, data }` (RGBA). */
430
+ async screenshot () {
431
+ this.flush()
432
+ return this.app.native.screenshot(this.id)
433
+ }
434
+
435
+ /** Click at (x, y) logical pixels (tests). */
436
+ click (x, y) {
437
+ this.flush()
438
+ this.app.native.simulateClick(this.id, x, y)
439
+ }
440
+
441
+ close () {
442
+ this.flush()
443
+ this.app.native.closeWindow(this.id)
444
+ this._closed()
445
+ }
446
+
447
+ on (event, fn) {
448
+ if (event !== 'close') throw new Error(`blenderui: unsupported window event "${event}"`)
449
+ this._onClose.add(fn)
450
+ return this
451
+ }
452
+
453
+ _closed () {
454
+ if (this.closed) return
455
+ this.closed = true
456
+ for (const fn of this._onClose) {
457
+ try { fn() } catch (err) { queueMicrotask(() => { throw err }) }
458
+ }
459
+ // the native window is gone: free what follows signals or holds listeners
460
+ for (const n of this._nodes.values()) {
461
+ n._unbindAll()
462
+ n._listeners?.clear()
463
+ }
464
+ this._frameCallbacks.clear()
465
+ this.flush()
466
+ this.app._windowClosed(this)
467
+ }
468
+
469
+ _op (...args) {
470
+ for (const a of args) this._ops.push(a)
471
+ if (!this._scheduled) {
472
+ this._scheduled = true
473
+ queueMicrotask(() => this.flush())
474
+ }
475
+ }
476
+
477
+ _s (str) {
478
+ let i = this._stringIds.get(str)
479
+ if (i === undefined) {
480
+ i = this._strings.push(str) - 1
481
+ this._stringIds.set(str, i)
482
+ }
483
+ return i
484
+ }
485
+
486
+ _free (node) {
487
+ node._unbindAll()
488
+ if (node.id === 0 || !this._nodes.delete(node.id)) return // the body, or freed already
489
+ this._op(OP.FREE, node.id)
490
+ }
491
+ }
492
+
493
+ class App {
494
+ /**
495
+ * @param {{ gpu?: boolean, slug?: boolean, headless?: boolean,
496
+ * quitOnLastWindowClosed?: boolean }} [options]
497
+ */
498
+ constructor (options = {}) {
499
+ this.windows = new Map()
500
+ this._nextWindow = 1
501
+ this.quitOnLastWindowClosed = options.quitOnLastWindowClosed ?? true
502
+ this.native = new native.NativeApp(
503
+ { gpu: options.gpu, slug: options.slug, headless: options.headless },
504
+ raw => this._event(raw))
505
+ }
506
+
507
+ get headless () { return this.native.headless }
508
+
509
+ /** Open a window: `{ title, width, height, resizable, gpu }`. */
510
+ window (options) {
511
+ const w = new Window(this, this._nextWindow++, options)
512
+ this.windows.set(w.id, w)
513
+ return w
514
+ }
515
+
516
+ /** GPU text: Slug (glyph outlines) or the glyph atlas. */
517
+ setSlug (on) { this.native.setSlug(!!on) }
518
+
519
+ /** Close every window and end the UI thread (Node may then exit). */
520
+ quit () {
521
+ if (this._quit) return
522
+ this._quit = true
523
+ for (const w of [...this.windows.values()]) {
524
+ w.flush()
525
+ w._closed()
526
+ }
527
+ this.native.quit()
528
+ }
529
+
530
+ _event (raw) {
531
+ const win = this.windows.get(raw.window)
532
+ if (!win) return
533
+ if (raw.kind === 'close') {
534
+ win._closed()
535
+ return
536
+ }
537
+ if (raw.kind === 'frame') {
538
+ win._frame()
539
+ return
540
+ }
541
+ win._nodes.get(raw.target)?._dispatch(raw)
542
+ }
543
+
544
+ _windowClosed (win) {
545
+ this.windows.delete(win.id)
546
+ if (this.quitOnLastWindowClosed && this.windows.size === 0 && !this._quit) {
547
+ this._quit = true
548
+ this.native.quit()
549
+ }
550
+ }
551
+ }
552
+
553
+ module.exports = { App, Window, Element, TextNode, Node, BuiEvent, styleText, BUTTONS, native, ...signals }
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@blender-ui/native",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
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
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "bin": {
8
+ "bui": "cli.js"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./index.d.ts",
13
+ "default": "./index.js"
14
+ },
15
+ "./signals": "./signals.js",
16
+ "./package.json": "./package.json"
17
+ },
18
+ "files": [
19
+ "index.js",
20
+ "signals.js",
21
+ "index.d.ts",
22
+ "cli.js",
23
+ "blenderui.*.node",
24
+ "bui-runtime.*",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "build": "node scripts/build.js",
30
+ "test": "node --test",
31
+ "prepublishOnly": "node scripts/build.js && node --test",
32
+ "prepack": "node -e \"require('fs').copyFileSync('../../LICENSE', 'LICENSE')\"",
33
+ "postpack": "node -e \"require('fs').rmSync('LICENSE', { force: true })\""
34
+ },
35
+ "engines": {
36
+ "node": ">=18"
37
+ },
38
+ "os": [
39
+ "win32"
40
+ ],
41
+ "cpu": [
42
+ "x64"
43
+ ],
44
+ "license": "GPL-2.0-or-later",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://github.com/ppmpreetham/BUI.git",
48
+ "directory": "bindings/node"
49
+ },
50
+ "homepage": "https://github.com/ppmpreetham/BUI/blob/main/docs/javascript.md",
51
+ "bugs": {
52
+ "url": "https://github.com/ppmpreetham/BUI/issues"
53
+ },
54
+ "keywords": [
55
+ "blender",
56
+ "ui",
57
+ "gui",
58
+ "desktop",
59
+ "native",
60
+ "napi",
61
+ "tailwind",
62
+ "css",
63
+ "signals",
64
+ "bun",
65
+ "react"
66
+ ]
67
+ }