react-x11 2.2.0 → 2.3.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.
@@ -0,0 +1,383 @@
1
+ // An ntk-window-shaped object over an NSWindow — the contract WindowNode
2
+ // realizes against (src/testing/mock-app.js is the reference shape; this
3
+ // file is that shape with real glass behind it).
4
+ //
5
+ // Units: everything crossing THIS object's boundary is device pixels, like
6
+ // an X window — attributes, reported width/height, event coordinates,
7
+ // _screenOrigin. The divide-by-scale into Cocoa points happens against the
8
+ // native layer and nowhere above it.
9
+ import { CocoaContext2D } from './context2d.js';
10
+ import { CocoaLayerPresenter } from './presenter.js';
11
+
12
+ let nextWindowId = 1;
13
+
14
+ export class CocoaWindow {
15
+ constructor(app, attributes = {}) {
16
+ this.app = app;
17
+ this._native = app._native;
18
+ this.attributes = attributes;
19
+ this.scale = app.scale;
20
+ this.id = nextWindowId++;
21
+ this.X = app.X;
22
+ this.destroyed = false;
23
+ this.mapped = false;
24
+ this._handlers = new Map();
25
+ this._surface = null;
26
+ this._surfaceGen = 0;
27
+ this._ctx = null;
28
+ this._dirty = false;
29
+
30
+ const s = this.scale;
31
+ // Snapped to whole POINTS: AppKit rounds window sizes to the point
32
+ // grid, so an odd device-pixel request comes back one short in the
33
+ // resize echo, the echo re-requests, and the backing surface churns —
34
+ // each swap an uninitialized canvas only the next damage rect repaints.
35
+ const snap = (v, fallback) =>
36
+ Math.max(1, Math.round(Math.max(1, Math.round(v ?? fallback)) / s) * s);
37
+ this.width = snap(attributes.width, 640);
38
+ this.height = snap(attributes.height, 480);
39
+ this.title = attributes.title ?? '';
40
+ this._popup = attributes.overrideRedirect === true;
41
+
42
+ const options = {
43
+ width: this.width / s,
44
+ height: this.height / s,
45
+ title: this.title,
46
+ kind: this._popup
47
+ ? 'popup'
48
+ : attributes.decorations === false
49
+ ? 'borderless'
50
+ : 'normal',
51
+ resizable: attributes.resizable !== false,
52
+ };
53
+ if (typeof attributes.x === 'number' && typeof attributes.y === 'number') {
54
+ options.x = attributes.x / s;
55
+ options.y = attributes.y / s;
56
+ }
57
+ // `transparent` arrives as a 32-bit visual request on X; here every
58
+ // window can composite, so the flag simply makes the glass clear.
59
+ const transparent =
60
+ attributes.visual !== undefined || attributes.transparent;
61
+ this._transparentWindow = Boolean(transparent);
62
+ if (transparent) options.opaque = false;
63
+ // The root layer's background is the "what newly exposed area shows"
64
+ // attribute an X window has — worth seeding on an opaque window so a
65
+ // resize flashes the right colour. On a transparent one it would sit
66
+ // OPAQUE behind the alpha the renderer paints (rounded corners went
67
+ // square behind it), and the honest ground there is nothing at all.
68
+ if (!transparent && attributes.backgroundColor !== undefined) {
69
+ const parsed = app._parseColor(attributes.backgroundColor);
70
+ if (parsed) options.backgroundColor = parsed;
71
+ }
72
+ this._h = this._native.createWindow2(options);
73
+ this.windowNumber = this._native.windowNumber(this._h);
74
+ this._layer = this._native.windowRootLayer(this._h);
75
+ this._refreshOrigin();
76
+ if (attributes.sizeHints) this.setSizeHints(attributes.sizeHints);
77
+
78
+ // The retained layer presenter (docs/macos.md Tier L), behind
79
+ // REACT_X11_COCOA_PRESENTER=layers while the surface path is the
80
+ // measured default. Its two hooks exist only in this mode, so the
81
+ // feature detection in nodes.js keeps the surface path byte-identical;
82
+ // the scroll blit is shadowed off because a layer frame has no backing
83
+ // bitmap to blit.
84
+ if (app._presenterMode === 'layers') {
85
+ this._presenter = new CocoaLayerPresenter(this);
86
+ this.presentFrame = (windowNode) => this._presenter.frame(windowNode);
87
+ this.noteInvalidate = (damage, layoutChanged) =>
88
+ this._presenter.noteInvalidate(damage, layoutChanged);
89
+ this.scrollRegion = null;
90
+ }
91
+ app._registerWindow(this);
92
+ }
93
+
94
+ // --- events --------------------------------------------------------------
95
+
96
+ on(name, fn) {
97
+ let list = this._handlers.get(name);
98
+ if (!list) this._handlers.set(name, (list = []));
99
+ list.push(fn);
100
+ }
101
+
102
+ emit(name, ev) {
103
+ const list = this._handlers.get(name);
104
+ if (!list) return;
105
+ for (const fn of [...list]) fn(ev);
106
+ }
107
+
108
+ // --- geometry ------------------------------------------------------------
109
+
110
+ _refreshOrigin() {
111
+ const f = this._native.getWindowFrame(this._h);
112
+ const s = this.scale;
113
+ this.x = Math.round(f.x * s);
114
+ this.y = Math.round(f.y * s);
115
+ this._screenOrigin = { x: this.x, y: this.y };
116
+ }
117
+
118
+ /** Native geometry changed (delegate event, points). */
119
+ _nativeResized(points) {
120
+ const s = this.scale;
121
+ this.width = Math.max(1, Math.round(points.width * s));
122
+ this.height = Math.max(1, Math.round(points.height * s));
123
+ this.x = Math.round(points.x * s);
124
+ this.y = Math.round(points.y * s);
125
+ this._screenOrigin = { x: this.x, y: this.y };
126
+ }
127
+
128
+ resize(width, height) {
129
+ const s = this.scale;
130
+ this.width = Math.max(1, Math.round(Math.round(width) / s) * s);
131
+ this.height = Math.max(1, Math.round(Math.round(height) / s) * s);
132
+ this._native.setWindowFrame(
133
+ this._h,
134
+ null,
135
+ null,
136
+ this.width / s,
137
+ this.height / s,
138
+ );
139
+ }
140
+
141
+ move(x, y) {
142
+ const s = this.scale;
143
+ this.x = Math.round(x);
144
+ this.y = Math.round(y);
145
+ this._native.setWindowFrame(this._h, x / s, y / s, null, null);
146
+ this._screenOrigin = { x: this.x, y: this.y };
147
+ }
148
+
149
+ // --- lifecycle -----------------------------------------------------------
150
+
151
+ map() {
152
+ if (this.destroyed) return;
153
+ this.mapped = true;
154
+ // A popup must not take the keyboard from its owner; a toplevel's first
155
+ // map is the app coming up and takes it.
156
+ this._native.showWindow(this._h, !this._popup);
157
+ this._refreshOrigin();
158
+ }
159
+
160
+ unmap() {
161
+ if (this.destroyed) return;
162
+ this.mapped = false;
163
+ this._native.hideWindow(this._h);
164
+ }
165
+
166
+ destroy() {
167
+ if (this.destroyed) return;
168
+ this.destroyed = true;
169
+ this.mapped = false;
170
+ this.app._unregisterWindow(this);
171
+ this._native.destroyWindow2(this._h);
172
+ this._surface = null;
173
+ }
174
+
175
+ // --- window-manager-ish surface (feature-detected by nodes.js) -----------
176
+
177
+ setTitle(title) {
178
+ this.title = title;
179
+ this._native.setWindowTitle(this._h, String(title ?? ''));
180
+ }
181
+
182
+ setSizeHints(hints = {}) {
183
+ const s = this.scale;
184
+ const box = {};
185
+ if (typeof hints.minWidth === 'number') box.minWidth = hints.minWidth / s;
186
+ if (typeof hints.minHeight === 'number')
187
+ box.minHeight = hints.minHeight / s;
188
+ if (typeof hints.maxWidth === 'number') box.maxWidth = hints.maxWidth / s;
189
+ if (typeof hints.maxHeight === 'number')
190
+ box.maxHeight = hints.maxHeight / s;
191
+ if (Object.keys(box).length) this._native.setWindowMinMax(this._h, box);
192
+ }
193
+
194
+ setClass() {}
195
+
196
+ setWindowType() {}
197
+
198
+ setActions() {}
199
+
200
+ setTransientFor() {
201
+ // addChildWindow attachment comes with the layer presenter phase; a
202
+ // managed dialog already floats via its own window today.
203
+ }
204
+
205
+ setCursor(name) {
206
+ this._native.setCursor(String(name ?? 'default'));
207
+ }
208
+
209
+ grabPointer(options, cb) {
210
+ this.app._grabWindow = this;
211
+ cb?.(null, 0);
212
+ }
213
+
214
+ ungrabPointer() {
215
+ if (this.app._grabWindow === this) this.app._grabWindow = null;
216
+ }
217
+
218
+ selectXI2() {
219
+ // AppKit's precise scroll deltas are already flowing; nothing to select.
220
+ return Promise.resolve(true);
221
+ }
222
+
223
+ // --- drawing -------------------------------------------------------------
224
+
225
+ /**
226
+ * The backing store is a two-buffer IOSurface swapchain: painters draw
227
+ * into the back buffer's CG bitmap, and presenting is `layer.contents =
228
+ * iosurface` — zero-copy, where the plain-surface path paid a
229
+ * window-sized CGImage copy per dirty frame (12ms at 900x700@2x — the
230
+ * presenter bench's whole surface-vs-layers gap on bounded damage).
231
+ * After a flip the new back buffer is one frame stale, so present copies
232
+ * the just-shown frame's damage across — a damage-sized memcpy replacing
233
+ * a window-sized upload. Falls back to the single plain surface where
234
+ * IOSurface creation fails.
235
+ */
236
+ _ensureSurface() {
237
+ const w = this.width;
238
+ const h = this.height;
239
+ if (
240
+ !this._surface ||
241
+ this._surfaceSize?.width !== w ||
242
+ this._surfaceSize?.height !== h
243
+ ) {
244
+ const hadSurface = Boolean(this._surface);
245
+ this._chain = null;
246
+ try {
247
+ const a = this._native.createSurfaceIOSurface(w, h, this.scale);
248
+ const b = this._native.createSurfaceIOSurface(w, h, this.scale);
249
+ this._chain = { back: a, front: b };
250
+ this._native.surfaceLock(a.handle);
251
+ this._native.ctxClearRect(a.handle, 0, 0, w, h);
252
+ this._native.ctxClearRect(b.handle, 0, 0, w, h);
253
+ this._surface = a.handle;
254
+ } catch {
255
+ this._surface = this._native.createSurface(w, h, this.scale);
256
+ this._native.ctxClearRect(this._surface, 0, 0, w, h);
257
+ }
258
+ this._surfaceSize = { width: w, height: h };
259
+ this._surfaceGen++;
260
+ this._flushDamage = 'full';
261
+ // A replaced backing surface holds nothing: whatever bounded damage
262
+ // this frame carries, everything else on it would be garbage. Ask for
263
+ // the full frame — one extra repaint per real resize, correctness for
264
+ // every pixel outside the damage rect.
265
+ if (hadSurface) {
266
+ queueMicrotask(() => {
267
+ const node = this._reactX11Node;
268
+ if (node && !node.destroyed) node.invalidate(true, null, 'resize');
269
+ });
270
+ }
271
+ }
272
+ return this._surface;
273
+ }
274
+
275
+ /**
276
+ * The per-flush painted rects (nodes.js's swapchain seam), accumulated
277
+ * until the next present: they are what the flip's catch-up copy covers.
278
+ * `'full'`/null collapse the set — one full copy beats bookkeeping.
279
+ */
280
+ noteFrameDamage(rects) {
281
+ if (this._presenter) return;
282
+ if (this._flushDamage === 'full') return;
283
+ if (!rects) {
284
+ this._flushDamage = 'full';
285
+ return;
286
+ }
287
+ (this._flushDamage ??= []).push(...rects);
288
+ }
289
+
290
+ getContext() {
291
+ if (!this._ctx) {
292
+ this._ctx = new CocoaContext2D(
293
+ this._native,
294
+ () => this._ensureSurface(),
295
+ () => {
296
+ this._ensureSurface();
297
+ return this._surfaceGen;
298
+ },
299
+ );
300
+ this._ctx._fonts = this.app.fonts;
301
+ this._ctx._onDirty = () => {
302
+ this._dirty = true;
303
+ };
304
+ }
305
+ return this._ctx;
306
+ }
307
+
308
+ /** The scroll-blit fast path: move pixels inside the backing surface,
309
+ * with ntk Window.scrollRegion's contract — the shift happens WITHIN the
310
+ * rect, and a delta that leaves no surviving band reports false so the
311
+ * caller falls back to the plain repaint. */
312
+ scrollRegion(rect, dx, dy) {
313
+ if (!this._surface) return false;
314
+ if (!Number.isInteger(dx) || !Number.isInteger(dy)) return false;
315
+ const moved = this._native.scrollSurface(
316
+ this._surface,
317
+ Math.round(rect.x),
318
+ Math.round(rect.y),
319
+ Math.round(rect.width),
320
+ Math.round(rect.height),
321
+ dx,
322
+ dy,
323
+ );
324
+ if (moved) this._dirty = true;
325
+ return Boolean(moved);
326
+ }
327
+
328
+ frameInFlight() {
329
+ return false;
330
+ }
331
+
332
+ requestAnimationFrame(cb) {
333
+ return this.app._requestFrame(cb);
334
+ }
335
+
336
+ /** Push the backing surface at the WindowServer, if anything drew. */
337
+ present() {
338
+ if (this._presenter) return; // layers upload as they sync
339
+ if (!this._dirty || !this._surface || this.destroyed) return;
340
+ this._dirty = false;
341
+ if (this._chain) {
342
+ const shown = this._chain.back;
343
+ this._native.surfaceUnlock(shown.handle);
344
+ this._native.setLayerContentsIOSurface(this._layer, shown.iosurfaceId);
345
+ this._chain.back = this._chain.front;
346
+ this._chain.front = shown;
347
+ this._surface = this._chain.back.handle;
348
+ // a different native surface owns the graphics state now — the
349
+ // context re-syncs its sticky state off the generation
350
+ this._surfaceGen++;
351
+ this._native.surfaceLock(this._surface);
352
+ const damage = this._flushDamage;
353
+ this._flushDamage = null;
354
+ this._native.copySurfaceRegion(
355
+ shown.handle,
356
+ this._surface,
357
+ damage === 'full' || !damage
358
+ ? null
359
+ : damage.flatMap((r) => [
360
+ Math.floor(r.x),
361
+ Math.floor(r.y),
362
+ Math.ceil(r.width) + 1,
363
+ Math.ceil(r.height) + 1,
364
+ ]),
365
+ );
366
+ if (this._transparentWindow) this.app._shadowStale.add(this);
367
+ return;
368
+ }
369
+ this._native.surfaceToLayer(this._surface, this._layer);
370
+ // AppKit derives a transparent window's shadow from the content's
371
+ // opaque shape and does not recompute it on repaints — a popup whose
372
+ // card lands a frame after the map keeps the full-frame square AppKit
373
+ // guessed first. Recompute — but only once this present's transaction
374
+ // has actually flushed to the render server, or the recompute reads
375
+ // the frame BEFORE this one and keeps the square rim for menus that
376
+ // paint once and are only hovered after.
377
+ if (this._transparentWindow) this.app._shadowStale.add(this);
378
+ }
379
+
380
+ snapshot(path) {
381
+ return this._native.snapshotWindow(this._h, path);
382
+ }
383
+ }
@@ -3,6 +3,14 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React from 'react';
6
+ import { useAppOrNull } from '../appcontext.js';
7
+ import {
8
+ ABS_FILL,
9
+ Bezel,
10
+ bezelNatural,
11
+ pressWash,
12
+ useNativeControls,
13
+ } from './native.js';
6
14
  import { labelContent, useControl, useTheme } from './theme.js';
7
15
 
8
16
  const h = React.createElement;
@@ -46,6 +54,7 @@ export function Button({
46
54
  variant = 'solid',
47
55
  size = 'medium',
48
56
  disabled = false,
57
+ native,
49
58
  style,
50
59
  ...boxProps
51
60
  }) {
@@ -61,12 +70,71 @@ export function Button({
61
70
  throw new Error(`<Button size="${size}">: one of ${SIZES.join(', ')}`);
62
71
  }
63
72
  const theme = useTheme();
73
+ const app = useAppOrNull();
74
+ const nativeControls = useNativeControls(native);
64
75
  const { props, style: controlStyle } = useControl(disabled, onPress, {
65
76
  styled: true,
66
77
  });
67
78
  const solid = variant === 'solid';
68
79
  const ghost = variant === 'ghost';
69
80
  const small = size === 'small';
81
+
82
+ // Only the solid variant has a native counterpart: outline and ghost are
83
+ // deliberately chrome-less designs AppKit has no bezel for, so they keep
84
+ // the drawn rendering under every policy.
85
+ if (nativeControls && solid) {
86
+ const controlSize = small ? 'small' : 'regular';
87
+ const nat = bezelNatural(app, 'push', controlSize);
88
+ return h(
89
+ 'box',
90
+ {
91
+ theme,
92
+ role: 'button',
93
+ ...props,
94
+ ...boxProps,
95
+ style: [
96
+ controlStyle,
97
+ {
98
+ flexDirection: 'row',
99
+ alignItems: 'center',
100
+ justifyContent: 'center',
101
+ gap: small ? 6 : 8,
102
+ // AppKit's metrics, not the palette's: a native bezel is
103
+ // designed at its own height, and stretching it is what this
104
+ // mode exists to avoid. Width still follows the label.
105
+ height: nat.height,
106
+ paddingLeft: small ? 10 : 14,
107
+ paddingRight: small ? 10 : 14,
108
+ color: disabled
109
+ ? theme.textMuted
110
+ : primary
111
+ ? theme.accentText
112
+ : theme.text,
113
+ },
114
+ style,
115
+ ],
116
+ },
117
+ h(Bezel, {
118
+ kind: 'push',
119
+ controlSize,
120
+ enabled: !disabled,
121
+ // the Return-key accent fill is AppKit's own "default button"
122
+ isDefault: primary && !disabled,
123
+ style: ABS_FILL,
124
+ }),
125
+ labelContent(children ?? label),
126
+ // The press answer. Last child on purpose: `:active` marks the
127
+ // pressed node and its ancestors, and the topmost child is what the
128
+ // press lands on. No hover tint — AppKit buttons have none.
129
+ h('box', {
130
+ style: [
131
+ ABS_FILL,
132
+ { borderRadius: small ? 5 : 6 },
133
+ !disabled && { ':active': { backgroundColor: pressWash(theme) } },
134
+ ],
135
+ }),
136
+ );
137
+ }
70
138
  const background = !solid
71
139
  ? // `transparent` rather than the ground's colour: an outline or ghost
72
140
  // button sits on whatever it sits on — a toolbar, a card, a table row —
@@ -3,8 +3,10 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React from 'react';
6
+ import { useAppOrNull } from '../appcontext.js';
6
7
  import { changeEvent } from './change.js';
7
8
  import { Icon } from './Icon.js';
9
+ import { Bezel, bezelNatural, useNativeControls } from './native.js';
8
10
  import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
9
11
 
10
12
  const h = React.createElement;
@@ -32,10 +34,13 @@ export function Checkbox({
32
34
  onChange,
33
35
  name,
34
36
  disabled = false,
37
+ native,
35
38
  style,
36
39
  ...boxProps
37
40
  }) {
38
41
  const theme = useTheme();
42
+ const app = useAppOrNull();
43
+ const nativeControls = useNativeControls(native);
39
44
  const {
40
45
  hover,
41
46
  focused,
@@ -45,6 +50,44 @@ export function Checkbox({
45
50
  } = useControl(disabled, () =>
46
51
  onChange?.(changeEvent('checkbox', name, !checked)),
47
52
  );
53
+
54
+ // Native mode swaps only the well: AppKit's checkbox bezel at its natural
55
+ // size, driven by the same React press state the drawn well uses — the
56
+ // pressed bezel is a re-keyed image, so the press still lands on the
57
+ // press frame. Focus keeps the shared ring (the one focus look the whole
58
+ // app has), and hover has no tint because AppKit checkboxes have none.
59
+ if (nativeControls) {
60
+ const nat = bezelNatural(app, 'checkbox');
61
+ return h(
62
+ 'box',
63
+ {
64
+ theme,
65
+ role: 'checkbox',
66
+ 'aria-checked': checked,
67
+ ...props,
68
+ ...boxProps,
69
+ style: [
70
+ controlStyle,
71
+ { flexDirection: 'row', alignItems: 'center', gap: 8 },
72
+ style,
73
+ ],
74
+ },
75
+ h(Bezel, {
76
+ kind: 'checkbox',
77
+ state: checked ? 1 : 0,
78
+ pressed,
79
+ enabled: !disabled,
80
+ style: {
81
+ width: nat.width,
82
+ height: nat.height,
83
+ ...focusRingStyle(theme, focused),
84
+ },
85
+ }),
86
+ labelContent(children ?? label, {
87
+ color: disabled ? theme.textMuted : theme.text,
88
+ }),
89
+ );
90
+ }
48
91
  const fill = disabled
49
92
  ? theme.textMuted
50
93
  : pressed
@@ -3,7 +3,9 @@
3
3
  // build-step-free for consumers.
4
4
 
5
5
  import React, { useContext, useEffect, useMemo, useRef } from 'react';
6
+ import { useAppOrNull } from '../appcontext.js';
6
7
  import { changeEvent } from './change.js';
8
+ import { Bezel, bezelNatural, useNativeControls } from './native.js';
7
9
  import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
8
10
  import { XK_DOWN, XK_LEFT, XK_RIGHT, XK_UP } from './keys.js';
9
11
 
@@ -52,8 +54,10 @@ export function RadioGroup({
52
54
  );
53
55
  }
54
56
 
55
- export function Radio({ value, children, label, disabled = false }) {
57
+ export function Radio({ value, children, label, disabled = false, native }) {
56
58
  const theme = useTheme();
59
+ const app = useAppOrNull();
60
+ const nativeControls = useNativeControls(native);
57
61
  const group = useContext(RadioGroupContext);
58
62
  if (!group) {
59
63
  throw new Error('react-x11: <Radio> must be inside a <RadioGroup>');
@@ -79,6 +83,38 @@ export function Radio({ value, children, label, disabled = false }) {
79
83
  else if (ev.keysym === XK_UP || ev.keysym === XK_LEFT) group.move(-1);
80
84
  };
81
85
  }
86
+
87
+ // The well swap Checkbox documents, with the radio bezel.
88
+ if (nativeControls) {
89
+ const nat = bezelNatural(app, 'radio');
90
+ return h(
91
+ 'box',
92
+ {
93
+ theme,
94
+ role: 'radio',
95
+ 'aria-checked': selected,
96
+ ...props,
97
+ style: [
98
+ controlStyle,
99
+ { flexDirection: 'row', alignItems: 'center', gap: 8 },
100
+ ],
101
+ },
102
+ h(Bezel, {
103
+ kind: 'radio',
104
+ state: selected ? 1 : 0,
105
+ pressed,
106
+ enabled: !disabled,
107
+ style: {
108
+ width: nat.width,
109
+ height: nat.height,
110
+ ...focusRingStyle(theme, focused),
111
+ },
112
+ }),
113
+ labelContent(children ?? label, {
114
+ color: disabled ? theme.textMuted : theme.text,
115
+ }),
116
+ );
117
+ }
82
118
  // The dot only appears on the release, so the well answers the press
83
119
  // itself — see Checkbox, both for the three-look treatment and for why
84
120
  // this is React state rather than an `:active` block.