@windowkit/appkit 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/LICENSE +19 -0
- package/README.md +192 -0
- package/binding.gyp +27 -0
- package/index.js +184 -0
- package/package.json +57 -0
- package/prebuilds/darwin-arm64/calayers.node +0 -0
- package/prebuilds/darwin-x64/calayers.node +0 -0
- package/scripts/install.js +54 -0
- package/src/addon.mm +966 -0
- package/src/backend.mm +2512 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
Copyright (C) 2026 by Andrey Sidorov
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
in the Software without restriction, including without limitation the rights
|
|
6
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in
|
|
11
|
+
all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
19
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# @windowkit/appkit
|
|
2
|
+
|
|
3
|
+
A **retained-mode AppKit backend for Node.js**: Core Animation (CALayer)
|
|
4
|
+
layer trees, CoreText layout and drawing, IOSurface presentation, NSMenu and
|
|
5
|
+
native control bezels. It is the macOS half of [react-x11][react-x11].
|
|
6
|
+
Instead of immediate-mode draw calls, you build a persistent tree of layers, mutate their
|
|
7
|
+
properties, and let the macOS WindowServer composite on the GPU — with implicit animations
|
|
8
|
+
and correct retina handling for free.
|
|
9
|
+
|
|
10
|
+
Built as a drawing-backend experiment for [react-x11]-style reconcilers: React elements map
|
|
11
|
+
1:1 to layers, and `commitUpdate` becomes `layer.set(props)`.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm install # builds the native addon (macOS only, needs Xcode CLT)
|
|
15
|
+
npm run demo # hover/click the cards; press "q" to quit
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## What the demo shows
|
|
19
|
+
|
|
20
|
+
- **CALayer tree** — frame/bounds/position, backgroundColor, cornerRadius, borderWidth,
|
|
21
|
+
shadows, opacity, zPosition, `masksToBounds` clipping
|
|
22
|
+
- **Implicit animations** — hover/click a card: plain `layer.set({...})` property changes
|
|
23
|
+
animate at 0.25s automatically
|
|
24
|
+
- **CATransaction** — the orange ball tweens over 1.1s with easeInEaseOut just by grouping
|
|
25
|
+
a `position` change in a transaction
|
|
26
|
+
- **Explicit CABasicAnimation** — the spinner runs two infinite animations
|
|
27
|
+
(`transform.rotation.z` + `strokeEnd`) entirely in the render server; they stay smooth
|
|
28
|
+
even if the JS thread stalls
|
|
29
|
+
- **CATextLayer** — retina-crisp text composited by the WindowServer
|
|
30
|
+
- **CoreText** — glyphs measured (`CTLine`) and rasterized (`CTFramesetter` → `CGImage`)
|
|
31
|
+
then set as `layer.contents`, i.e. the glyph-atlas path
|
|
32
|
+
- **CAGradientLayer + layer.mask** — the footer is a gradient masked by a text layer
|
|
33
|
+
- **CAShapeLayer** — CGPath commands, stroke/fill, dash patterns, animatable `strokeEnd`
|
|
34
|
+
- **Hit testing** — native `-[CALayer hitTest:]` mapped back to JS wrapper objects
|
|
35
|
+
- **Events** — mouse/keyboard from the NSApp event pump delivered to a JS callback
|
|
36
|
+
- **Native controls** — push buttons (incl. accent-filled default), checkboxes, radios,
|
|
37
|
+
popup buttons, sliders, and switches rendered by AppKit itself and composited as layer
|
|
38
|
+
contents; fully interactive (pressed states, toggles, slider drag) and re-renderable in
|
|
39
|
+
dark/light appearance (the "Light / Dark" button flips all of them live)
|
|
40
|
+
|
|
41
|
+
## How it runs
|
|
42
|
+
|
|
43
|
+
Node's main thread *is* the process main thread on macOS, so the addon owns
|
|
44
|
+
`NSApplication` directly. Nobody calls `[NSApp run]`; instead JS drives an event pump
|
|
45
|
+
(`nextEventMatchingMask:` with `distantPast`) off a `setInterval`. Core Animation
|
|
46
|
+
animations execute in the render server, so their smoothness is independent of the pump
|
|
47
|
+
cadence — the JS timer only affects input latency.
|
|
48
|
+
|
|
49
|
+
The window uses a **layer-hosting** `NSView` (we own the whole CALayer tree) with
|
|
50
|
+
`isFlipped = YES`, which makes AppKit give the hosted layer a top-left origin
|
|
51
|
+
(`geometryFlipped`) — coordinates match what a UI toolkit expects. Two flip gotchas are
|
|
52
|
+
handled in native code: `hitTest:` still takes bottom-up points, and
|
|
53
|
+
`renderInContext:` ignores `geometryFlipped` entirely (snapshots therefore capture the
|
|
54
|
+
window's real composited pixels via `CGWindowListCreateImage`, which needs no
|
|
55
|
+
screen-recording permission for the process's own windows).
|
|
56
|
+
|
|
57
|
+
## API sketch
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
const ca = require('@windowkit/appkit');
|
|
61
|
+
const { app, Window, Layer, TextLayer, GradientLayer, ShapeLayer,
|
|
62
|
+
transaction, withoutAnimations } = ca;
|
|
63
|
+
|
|
64
|
+
const win = new Window({ width: 800, height: 560, title: 'hi' });
|
|
65
|
+
|
|
66
|
+
const card = new Layer();
|
|
67
|
+
card.set({
|
|
68
|
+
frame: [32, 108, 228, 128], // top-left origin, points (not pixels)
|
|
69
|
+
backgroundColor: [0.98, 0.42, 0.36, 1],
|
|
70
|
+
cornerRadius: 14,
|
|
71
|
+
shadowOpacity: 0.5, shadowRadius: 12, shadowOffset: [0, 6],
|
|
72
|
+
});
|
|
73
|
+
win.root.add(card);
|
|
74
|
+
|
|
75
|
+
// implicit animation: just set the property
|
|
76
|
+
card.set({ backgroundColor: [0.36, 0.65, 0.98, 1] });
|
|
77
|
+
|
|
78
|
+
// batched, with custom duration/curve
|
|
79
|
+
transaction(() => card.set({ position: [400, 300] }),
|
|
80
|
+
{ duration: 1.1, timing: 'easeInEaseOut' });
|
|
81
|
+
|
|
82
|
+
// no animation (e.g. initial tree construction, reconciler commits)
|
|
83
|
+
withoutAnimations(() => card.set({ opacity: 0.5 }));
|
|
84
|
+
|
|
85
|
+
// explicit animation on any animatable keyPath
|
|
86
|
+
card.animate('transform.rotation.z',
|
|
87
|
+
{ from: 0, to: Math.PI * 2, duration: 1, repeat: Infinity, timing: 'linear' });
|
|
88
|
+
|
|
89
|
+
// text, two ways
|
|
90
|
+
const label = new TextLayer();
|
|
91
|
+
label.set({ frame: [0, 14, 228, 22], contentsScale: win.scale })
|
|
92
|
+
.text({ string: 'hello', fontName: 'HelveticaNeue', fontSize: 15,
|
|
93
|
+
color: [1, 1, 1, 1], align: 'center' });
|
|
94
|
+
card.add(label);
|
|
95
|
+
|
|
96
|
+
const glyphs = ca.text.render({ text: 'CoreText', fontName: 'Menlo',
|
|
97
|
+
fontSize: 13, color: [1, 1, 1, 1], scale: win.scale });
|
|
98
|
+
new Layer().set({ frame: [10, 10, glyphs.width, glyphs.height] }).setImage(glyphs);
|
|
99
|
+
ca.text.measure({ text: 'CoreText', fontName: 'Menlo', fontSize: 13 });
|
|
100
|
+
// -> { width, ascent, descent, leading }
|
|
101
|
+
|
|
102
|
+
// masks, gradients, shapes
|
|
103
|
+
const g = new GradientLayer();
|
|
104
|
+
g.gradient({ colors: [[1, 0, 0, 1], [0, 0, 1, 1]], startPoint: [0, 0.5], endPoint: [1, 0.5] });
|
|
105
|
+
g.set({ mask: someTextLayer });
|
|
106
|
+
|
|
107
|
+
const shape = new ShapeLayer();
|
|
108
|
+
shape.shape({ path: [['move', 0, 0], ['line', 50, 80], ['arc', 25, 25, 20, 0, Math.PI, false]],
|
|
109
|
+
strokeColor: [1, 1, 1, 1], lineWidth: 4, lineCap: 'round', fillColor: null });
|
|
110
|
+
|
|
111
|
+
// input + hit testing
|
|
112
|
+
app.onEvent((ev) => { // mousedown/up/move/drag, wheel, keydown/up
|
|
113
|
+
const layer = win.hitTest(ev.x, ev.y); // deepest Layer wrapper or null
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
app.run({ onTick: () => { if (!win.visible) process.exit(0); } });
|
|
117
|
+
|
|
118
|
+
win.snapshot('/tmp/out.png'); // real composited pixels of the window
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Native controls
|
|
122
|
+
|
|
123
|
+
There is no WindowServer API for control drawing — AppKit draws controls in-process via
|
|
124
|
+
the **NSCell** architecture, and cells happily draw offscreen (the technique WebKit's
|
|
125
|
+
`RenderThemeMac` and Firefox's `nsNativeThemeCocoa` use for native form controls).
|
|
126
|
+
`ca.controls.render()` rasterizes a cell at retina scale under a chosen `NSAppearance`
|
|
127
|
+
and returns a `CGImage` for `layer.contents`:
|
|
128
|
+
|
|
129
|
+
```js
|
|
130
|
+
const img = ca.controls.render({
|
|
131
|
+
kind: 'push', // 'push' | 'checkbox' | 'radio' | 'popup' | 'slider' | 'switch'
|
|
132
|
+
title: 'Click me',
|
|
133
|
+
pressed: false, // drive this from your own mouse events
|
|
134
|
+
state: 1, // on/off for checkbox/radio/switch
|
|
135
|
+
isDefault: true, // push: accent-filled default button
|
|
136
|
+
value: 0.5, // slider position
|
|
137
|
+
controlSize: 'regular', // 'mini' | 'small' | 'regular' | 'large'
|
|
138
|
+
appearance: 'dark', // 'system' | 'dark' | 'light'
|
|
139
|
+
scale: win.scale,
|
|
140
|
+
}); // -> { image, width, height, scale } (natural cellSize if
|
|
141
|
+
// width/height omitted)
|
|
142
|
+
new Layer().set({ frame: [x, y, img.width, img.height] }).setImage(img);
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Two render paths inside `drawControl`:
|
|
146
|
+
|
|
147
|
+
- **Cell path** (`NSButtonCell`, `NSPopUpButtonCell`): `drawWithFrame:inView:` into a
|
|
148
|
+
bitmap `NSGraphicsContext`, wrapped in `performAsCurrentDrawingAppearance:` so dark
|
|
149
|
+
mode and the user's accent color apply.
|
|
150
|
+
- **Offscreen-view path** (`NSSlider`, `NSSwitch`): modern `NSSliderCell` no longer
|
|
151
|
+
draws offscreen (it defers to the view's layer machinery), and `NSSwitch` has no cell
|
|
152
|
+
at all, so these render a real unparented `NSControl` via
|
|
153
|
+
`displayRectIgnoringOpacity:inContext:`.
|
|
154
|
+
|
|
155
|
+
The demo re-renders a control's image on each state change; a real renderer would cache
|
|
156
|
+
per `(kind, size, state, appearance)` and nine-slice-stretch bezels with
|
|
157
|
+
`layer.contentsCenter`. Menus/popovers are deliberately *not* painted — their
|
|
158
|
+
vibrancy materials need private API to reproduce; expose real `NSMenu` instead.
|
|
159
|
+
|
|
160
|
+
`native.postMouseEvent(win, 'down'|'up'|'move'|'drag', x, y)` synthesizes events through
|
|
161
|
+
the real pump — used by the demo's self-test (`CAL_CLICKS="x,y;x,y" npm run demo`).
|
|
162
|
+
|
|
163
|
+
## Mapping to a React reconciler
|
|
164
|
+
|
|
165
|
+
The shape of a host config on top of this:
|
|
166
|
+
|
|
167
|
+
| Reconciler op | @windowkit/appkit |
|
|
168
|
+
| -------------------- | -------------------------------------------------------- |
|
|
169
|
+
| `createInstance` | `new Layer()` / `new TextLayer()` / ... per element type |
|
|
170
|
+
| `appendChild` | `parent.add(child)` |
|
|
171
|
+
| `removeChild` | `child.remove()` |
|
|
172
|
+
| `commitUpdate` | `layer.set(diffedProps)` |
|
|
173
|
+
| commit batch | wrap in `withoutAnimations()` (or a `transaction()` to get animated updates for free) |
|
|
174
|
+
| `getPublicInstance` | the `Layer` wrapper (hit-testing gives it back for events) |
|
|
175
|
+
|
|
176
|
+
Because the tree is retained and properties are mutable, the reconciler diff maps directly
|
|
177
|
+
onto layer mutations — no repaint pass, no damage rects; the WindowServer recomposites
|
|
178
|
+
only what changed.
|
|
179
|
+
|
|
180
|
+
## Caveats (POC)
|
|
181
|
+
|
|
182
|
+
- The pump-on-a-timer model means live window resizing/dragging runs AppKit's internal
|
|
183
|
+
modal loops; input during those is choppy (Core Animation itself is unaffected).
|
|
184
|
+
- No `NSWindowDelegate` wiring yet — window resize is observable only by polling
|
|
185
|
+
`win.size`; sublayers don't autolayout (by design — the reconciler owns layout).
|
|
186
|
+
- One shared event callback for all windows; per-window routing would need the window
|
|
187
|
+
handle in the event payload.
|
|
188
|
+
- Layer handles are released on GC via External finalizers; native side keeps its own
|
|
189
|
+
retains through the layer tree, so lifetime is safe but not tuned.
|
|
190
|
+
- `x64`/`arm64` follows whatever node arch you build with; no prebuilds.
|
|
191
|
+
|
|
192
|
+
[react-x11]: https://github.com/sidorares/react-x11
|
package/binding.gyp
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"targets": [
|
|
3
|
+
{
|
|
4
|
+
"target_name": "calayers",
|
|
5
|
+
"sources": ["src/addon.mm", "src/backend.mm"],
|
|
6
|
+
"include_dirs": [
|
|
7
|
+
"<!@(node -p \"require('node-addon-api').include\")"
|
|
8
|
+
],
|
|
9
|
+
"defines": ["NAPI_DISABLE_CPP_EXCEPTIONS"],
|
|
10
|
+
"xcode_settings": {
|
|
11
|
+
"OTHER_CPLUSPLUSFLAGS": ["-fobjc-arc", "-std=c++17"],
|
|
12
|
+
"MACOSX_DEPLOYMENT_TARGET": "11.0",
|
|
13
|
+
"CLANG_ENABLE_OBJC_ARC": "YES"
|
|
14
|
+
},
|
|
15
|
+
"link_settings": {
|
|
16
|
+
"libraries": [
|
|
17
|
+
"-framework Cocoa",
|
|
18
|
+
"-framework QuartzCore",
|
|
19
|
+
"-framework CoreText",
|
|
20
|
+
"-framework CoreGraphics",
|
|
21
|
+
"-framework ImageIO",
|
|
22
|
+
"-framework IOSurface"
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
if (process.platform !== 'darwin') {
|
|
4
|
+
throw new Error('@windowkit/appkit is macOS-only (Core Animation backend)');
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
// A local build wins (dev iteration), then the prebuilt binary bundled in
|
|
11
|
+
// the npm tarball for this platform/arch (see scripts/install.js — the
|
|
12
|
+
// package works even when install scripts are disabled), then a clear error.
|
|
13
|
+
function loadNative() {
|
|
14
|
+
const candidates = [
|
|
15
|
+
'build/Release/calayers.node',
|
|
16
|
+
'build/Debug/calayers.node',
|
|
17
|
+
`prebuilds/${process.platform}-${process.arch}/calayers.node`,
|
|
18
|
+
];
|
|
19
|
+
const errors = [];
|
|
20
|
+
for (const rel of candidates) {
|
|
21
|
+
const abs = path.join(__dirname, rel);
|
|
22
|
+
if (!fs.existsSync(abs)) continue;
|
|
23
|
+
try {
|
|
24
|
+
return require(abs);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
errors.push(` ${rel}: ${e.message}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
throw new Error(
|
|
30
|
+
`@windowkit/appkit: no loadable native binary for ${process.platform}-${process.arch}\n` +
|
|
31
|
+
(errors.length ? `tried:\n${errors.join('\n')}\n` : '') +
|
|
32
|
+
'rebuild with: npm rebuild @windowkit/appkit --build-from-source (needs the Xcode command-line tools)',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const native = loadNative();
|
|
37
|
+
|
|
38
|
+
// name -> wrapper, so native hitTest results map back to JS objects
|
|
39
|
+
const layersByName = new Map();
|
|
40
|
+
let seq = 0;
|
|
41
|
+
|
|
42
|
+
class Layer {
|
|
43
|
+
constructor(handle) {
|
|
44
|
+
this._h = handle || native.createLayer();
|
|
45
|
+
this._name = 'layer:' + ++seq;
|
|
46
|
+
native.setLayerProps(this._h, { name: this._name });
|
|
47
|
+
layersByName.set(this._name, this);
|
|
48
|
+
this.parent = null;
|
|
49
|
+
this.children = [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Retained-mode property update. Changes to position/bounds/backgroundColor/
|
|
53
|
+
// opacity/cornerRadius/transform/... on layers already in a tree get implicit
|
|
54
|
+
// 0.25s animations from Core Animation unless wrapped in withoutAnimations().
|
|
55
|
+
set(props) {
|
|
56
|
+
if (props.mask instanceof Layer) props = { ...props, mask: props.mask._h };
|
|
57
|
+
native.setLayerProps(this._h, props);
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
add(child) {
|
|
62
|
+
native.addSublayer(this._h, child._h);
|
|
63
|
+
child.parent = this;
|
|
64
|
+
this.children.push(child);
|
|
65
|
+
return child;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
remove() {
|
|
69
|
+
native.removeFromSuperlayer(this._h);
|
|
70
|
+
if (this.parent) {
|
|
71
|
+
const i = this.parent.children.indexOf(this);
|
|
72
|
+
if (i >= 0) this.parent.children.splice(i, 1);
|
|
73
|
+
this.parent = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Explicit CABasicAnimation on any animatable keyPath, e.g.
|
|
78
|
+
// 'transform.rotation.z', 'position', 'opacity', 'strokeEnd', 'backgroundColor'.
|
|
79
|
+
animate(keyPath, opts = {}, key = keyPath) {
|
|
80
|
+
native.addAnimation(this._h, keyPath, opts, key);
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
removeAnimation(key) { native.removeAnimation(this._h, key); }
|
|
85
|
+
removeAllAnimations() { native.removeAllAnimations(this._h); }
|
|
86
|
+
|
|
87
|
+
// img is the result of text.render() (or any {image, scale})
|
|
88
|
+
setImage(img, scale) {
|
|
89
|
+
native.setContentsImage(this._h, img.image, scale ?? img.scale);
|
|
90
|
+
return this;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
class TextLayer extends Layer {
|
|
95
|
+
constructor() { super(native.createTextLayer()); }
|
|
96
|
+
// {string, fontName, fontSize, color, align, wrapped, truncation}
|
|
97
|
+
text(props) { native.setTextProps(this._h, props); return this; }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
class GradientLayer extends Layer {
|
|
101
|
+
constructor() { super(native.createGradientLayer()); }
|
|
102
|
+
// {colors: [[r,g,b,a],...], locations, startPoint, endPoint, type}
|
|
103
|
+
gradient(props) { native.setGradientProps(this._h, props); return this; }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
class ShapeLayer extends Layer {
|
|
107
|
+
constructor() { super(native.createShapeLayer()); }
|
|
108
|
+
// {path: [['move',x,y],['line',x,y],['arc',cx,cy,r,a0,a1,cw],...],
|
|
109
|
+
// fillColor, strokeColor, lineWidth, strokeStart, strokeEnd, lineCap, ...}
|
|
110
|
+
shape(props) { native.setShapeProps(this._h, props); return this; }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
class Window {
|
|
114
|
+
constructor({ width = 640, height = 480, title = '' } = {}) {
|
|
115
|
+
native.initApp();
|
|
116
|
+
this._h = native.createWindow(width, height, title);
|
|
117
|
+
this.root = new Layer(native.windowRootLayer(this._h));
|
|
118
|
+
this.scale = native.windowScale(this._h); // backing scale (2 on retina)
|
|
119
|
+
}
|
|
120
|
+
get size() {
|
|
121
|
+
const s = native.windowContentSize(this._h);
|
|
122
|
+
return { width: s[0], height: s[1] };
|
|
123
|
+
}
|
|
124
|
+
get visible() { return native.windowIsVisible(this._h); }
|
|
125
|
+
hitTest(x, y) {
|
|
126
|
+
const name = native.hitTest(this.root._h, x, y);
|
|
127
|
+
return (name && layersByName.get(name)) || null;
|
|
128
|
+
}
|
|
129
|
+
close() { native.closeWindow(this._h); }
|
|
130
|
+
snapshot(file) { return native.snapshotWindow(this._h, file); } // renderInContext -> PNG
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Group property changes into one CATransaction (shared animation duration/timing).
|
|
134
|
+
function transaction(fn, opts = {}) {
|
|
135
|
+
native.txBegin(opts);
|
|
136
|
+
try { fn(); } finally { native.txCommit(); }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Suppress implicit animations for this batch of changes.
|
|
140
|
+
function withoutAnimations(fn) {
|
|
141
|
+
transaction(fn, { disableActions: true });
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let timer = null;
|
|
145
|
+
const app = {
|
|
146
|
+
onEvent(fn) { native.setEventCallback(fn); },
|
|
147
|
+
// Drives the NSApplication event pump off node's event loop. Core Animation
|
|
148
|
+
// itself animates in the render server, independent of this cadence.
|
|
149
|
+
run({ fps = 60, onTick } = {}) {
|
|
150
|
+
if (timer) return;
|
|
151
|
+
native.initApp();
|
|
152
|
+
timer = setInterval(() => {
|
|
153
|
+
native.pump();
|
|
154
|
+
if (onTick) onTick();
|
|
155
|
+
}, Math.max(4, Math.floor(1000 / fps)));
|
|
156
|
+
},
|
|
157
|
+
stop() {
|
|
158
|
+
if (timer) { clearInterval(timer); timer = null; }
|
|
159
|
+
},
|
|
160
|
+
pump: () => native.pump(),
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const text = {
|
|
164
|
+
// CoreText-rendered glyphs -> CGImage, for glyph-atlas style text.
|
|
165
|
+
// {text, fontName, fontSize, color, maxWidth, scale} -> {image, width, height, scale}
|
|
166
|
+
render: (opts) => native.createTextImage(opts),
|
|
167
|
+
// {text, fontName, fontSize} -> {width, ascent, descent, leading}
|
|
168
|
+
measure: (opts) => native.measureText(opts),
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const controls = {
|
|
172
|
+
// Native control bezels via offscreen NSCell drawing (WebKit/Firefox technique).
|
|
173
|
+
// {kind: 'push'|'checkbox'|'radio'|'popup'|'slider', title, state, pressed,
|
|
174
|
+
// enabled, isDefault, value, controlSize, appearance: 'system'|'dark'|'light',
|
|
175
|
+
// width, height, scale} -> {image, width, height, scale}
|
|
176
|
+
// width/height default to the cell's natural size (sliders must pass them).
|
|
177
|
+
render: (opts) => native.drawControl(opts),
|
|
178
|
+
isDark: () => native.appearanceIsDark(),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
module.exports = {
|
|
182
|
+
app, Window, Layer, TextLayer, GradientLayer, ShapeLayer,
|
|
183
|
+
transaction, withoutAnimations, text, controls, native,
|
|
184
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@windowkit/appkit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Retained-mode AppKit backend for Node.js: Core Animation (CALayer) layer trees, CoreText layout and drawing, IOSurface presentation, NSMenu, and native control bezels — the macOS half of react-x11.",
|
|
5
|
+
"author": "Andrey Sidorov <andrey.sidorov@gmail.com>",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"homepage": "https://github.com/windowkit/appkit",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/windowkit/appkit.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/windowkit/appkit/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"macos",
|
|
18
|
+
"appkit",
|
|
19
|
+
"core-animation",
|
|
20
|
+
"calayer",
|
|
21
|
+
"coretext",
|
|
22
|
+
"iosurface",
|
|
23
|
+
"nsmenu",
|
|
24
|
+
"native-controls",
|
|
25
|
+
"react-x11",
|
|
26
|
+
"windowkit"
|
|
27
|
+
],
|
|
28
|
+
"os": [
|
|
29
|
+
"darwin"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=18"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"LICENSE",
|
|
36
|
+
"index.js",
|
|
37
|
+
"binding.gyp",
|
|
38
|
+
"src/",
|
|
39
|
+
"scripts/",
|
|
40
|
+
"prebuilds/"
|
|
41
|
+
],
|
|
42
|
+
"scripts": {
|
|
43
|
+
"install": "node scripts/install.js",
|
|
44
|
+
"build": "node-gyp rebuild",
|
|
45
|
+
"demo": "node demo/demo.js"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"node-addon-api": "^8.3.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"node-gyp": "^11.0.0"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
},
|
|
56
|
+
"gypfile": true
|
|
57
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Install-time dispatch: use the bundled prebuilt binary when one matches
|
|
2
|
+
// this platform/arch and actually loads; otherwise compile with node-gyp.
|
|
3
|
+
// A Mac with no Xcode command-line tools therefore installs from the
|
|
4
|
+
// tarball alone. Force a source build with: npm install --build-from-source
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
const { spawnSync } = require('child_process');
|
|
10
|
+
|
|
11
|
+
// darwin-only package (AppKit / Core Animation). On any other platform the
|
|
12
|
+
// package is inert; there is nothing to build, and `os` in package.json
|
|
13
|
+
// already warns npm — do not fail the install of a cross-platform consumer.
|
|
14
|
+
if (process.platform !== 'darwin') {
|
|
15
|
+
console.log('@windowkit/appkit: not macOS — skipping native build');
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const root = path.join(__dirname, '..');
|
|
20
|
+
const target = `${process.platform}-${process.arch}`;
|
|
21
|
+
const prebuilt = path.join(root, 'prebuilds', target, 'calayers.node');
|
|
22
|
+
|
|
23
|
+
if (
|
|
24
|
+
process.env.npm_config_build_from_source !== 'true' &&
|
|
25
|
+
fs.existsSync(prebuilt)
|
|
26
|
+
) {
|
|
27
|
+
try {
|
|
28
|
+
require(prebuilt); // loading registers exports and touches nothing else
|
|
29
|
+
console.log(`@windowkit/appkit: using bundled prebuilt binary (${target})`);
|
|
30
|
+
process.exit(0);
|
|
31
|
+
} catch (e) {
|
|
32
|
+
console.warn(
|
|
33
|
+
`@windowkit/appkit: bundled prebuild did not load (${e.message}); compiling instead`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// npm points npm_config_node_gyp at its own copy; fall back to PATH
|
|
39
|
+
const gyp = process.env.npm_config_node_gyp;
|
|
40
|
+
const result = gyp
|
|
41
|
+
? spawnSync(process.execPath, [gyp, 'rebuild'], { cwd: root, stdio: 'inherit' })
|
|
42
|
+
: spawnSync('node-gyp', ['rebuild'], { cwd: root, stdio: 'inherit' });
|
|
43
|
+
if (result.error) {
|
|
44
|
+
console.error(
|
|
45
|
+
`@windowkit/appkit: could not run node-gyp (${result.error.message})`,
|
|
46
|
+
);
|
|
47
|
+
console.error(
|
|
48
|
+
'@windowkit/appkit: no prebuilt binary matches this system and no ' +
|
|
49
|
+
'toolchain is available. Install the Xcode command-line tools ' +
|
|
50
|
+
'(xcode-select --install) and reinstall.',
|
|
51
|
+
);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
process.exit(result.status === null ? 1 : result.status);
|