@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/LICENSE +674 -0
- package/README.md +125 -2
- package/blenderui.win32-x64.node +0 -0
- package/bui-runtime.win32-x64.exe +0 -0
- package/cli.js +138 -0
- package/index.d.ts +187 -0
- package/index.js +553 -0
- package/package.json +65 -4
- package/signals.js +628 -0
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|