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