react-x11 2.29.0 → 2.30.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/package.json +1 -1
- package/src/cocoa/app.js +157 -9
- package/src/cocoa/panehost.js +226 -2
- package/src/cocoa/panewindow.js +204 -14
- package/src/cocoa/promotion.js +21 -1
- package/src/cocoa/sprites.js +54 -11
- package/src/components/Select.js +98 -7
- package/src/frame/index.js +60 -7
- package/src/node.d.ts +5 -0
- package/src/nodes/window/window.js +11 -1
- package/src/types/components.d.ts +10 -0
package/package.json
CHANGED
package/src/cocoa/app.js
CHANGED
|
@@ -34,7 +34,7 @@ import { CocoaCalendars } from './calendar.js';
|
|
|
34
34
|
import { CocoaNotifications } from './notifications.js';
|
|
35
35
|
import { CocoaPaneHost } from './panehost.js';
|
|
36
36
|
import { CocoaPermissions } from './permissions.js';
|
|
37
|
-
import { CocoaPaneWindow } from './panewindow.js';
|
|
37
|
+
import { CocoaPaneSubwindow, CocoaPaneWindow } from './panewindow.js';
|
|
38
38
|
import { CocoaColorSampler } from './screencolor.js';
|
|
39
39
|
import { CocoaFilePanels } from './filepanels.js';
|
|
40
40
|
import { CocoaFontManager } from './fonts.js';
|
|
@@ -331,6 +331,11 @@ export class CocoaApp {
|
|
|
331
331
|
// composites (REACT_X11_FRAME is what the Frame host sets on the fork).
|
|
332
332
|
this._paneMode = options.pane ?? process.env.REACT_X11_FRAME === '1';
|
|
333
333
|
this._paneSend = null;
|
|
334
|
+
// the pane's own window, the one the host lays out and forwards to
|
|
335
|
+
this._paneWindow = null;
|
|
336
|
+
// What the pane's other windows said before the host was listening
|
|
337
|
+
// (`_postWindow`), until its first pane-rect.
|
|
338
|
+
this._paneOutbox = [];
|
|
334
339
|
|
|
335
340
|
// The X stub: just enough for the modules that carry an X escape hatch
|
|
336
341
|
// to no-op the way they do against the headless mock.
|
|
@@ -447,7 +452,18 @@ export class CocoaApp {
|
|
|
447
452
|
// shared IOSurfaces and the HOST composites them (src/cocoa/
|
|
448
453
|
// panewindow.js). Chosen by the same prop the X11 pane uses.
|
|
449
454
|
if (this._paneMode && attributes.embeddable) {
|
|
450
|
-
|
|
455
|
+
const wnd = new CocoaPaneWindow(this, attributes);
|
|
456
|
+
if (!this._paneWindow || this._paneWindow.destroyed) {
|
|
457
|
+
this._paneWindow = wnd;
|
|
458
|
+
}
|
|
459
|
+
return wnd;
|
|
460
|
+
}
|
|
461
|
+
// Any other window a pane makes — a menu, a tooltip, a dialog — is
|
|
462
|
+
// drawn here and shown by the host, which has the NSApplication this
|
|
463
|
+
// process does not (#824). An NSWindow made here exists and is never on
|
|
464
|
+
// glass.
|
|
465
|
+
if (this._paneMode && !attributes.parent) {
|
|
466
|
+
return new CocoaPaneSubwindow(this, attributes);
|
|
451
467
|
}
|
|
452
468
|
if (attributes.parent) {
|
|
453
469
|
// A parented "window" here is a GL child surface — GlAreaNode's
|
|
@@ -487,10 +503,88 @@ export class CocoaApp {
|
|
|
487
503
|
/**
|
|
488
504
|
* The Frame host seam (src/frame/index.js): a pane's composited region
|
|
489
505
|
* in this window. Its presence is what routes <Frame> to the shared-
|
|
490
|
-
* memory pane path instead of the X foreign-window embed.
|
|
506
|
+
* memory pane path instead of the X foreign-window embed. `send` is the
|
|
507
|
+
* pane's channel, for the windows the pane makes besides its own: a pane
|
|
508
|
+
* process runs no AppKit, so they are made here (src/cocoa/panehost.js).
|
|
491
509
|
*/
|
|
492
|
-
createPaneHost(wnd) {
|
|
493
|
-
return new CocoaPaneHost(this, wnd);
|
|
510
|
+
createPaneHost(wnd, { send } = {}) {
|
|
511
|
+
return new CocoaPaneHost(this, wnd, { send });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Whether this backend drops a control's menu as the platform's own — a
|
|
516
|
+
* bridge with `popUpMenu` (@windowkit/appkit 0.20) — rather than a drawn
|
|
517
|
+
* `<popup>` sized as one. What `<Select>` asks before it opens.
|
|
518
|
+
*/
|
|
519
|
+
get nativePopUpMenus() {
|
|
520
|
+
return typeof this._native?.popUpMenu === 'function';
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* The platform's own menu, dropped from a control in one of this app's
|
|
525
|
+
* windows as an NSPopUpButton's drops: the item `spec.selected` placed
|
|
526
|
+
* over `spec.frame` and checked, the menu at least the frame's width.
|
|
527
|
+
* `spec` is the bridge's (`items`, `selected`, `fontSize`, `fontFamily`,
|
|
528
|
+
* `appearance`, `rtl`) with `frame` in the window's device pixels, which
|
|
529
|
+
* every rect on this side is. `onAnswer(id | null)` hears the item chosen,
|
|
530
|
+
* or null for a menu dismissed, once; the frame that answer causes goes
|
|
531
|
+
* out with it. Returns a function that cancels the menu.
|
|
532
|
+
*
|
|
533
|
+
* Opened on a microtask, after the input that asked for it has been
|
|
534
|
+
* answered and painted: in pump mode the menu's tracking holds this thread
|
|
535
|
+
* from the call to the choice, and the press that opened it should be on
|
|
536
|
+
* glass before it does. From a worker the call returns at once.
|
|
537
|
+
*
|
|
538
|
+
* A pane has no NSWindow to drop it from, so in a pane the host does,
|
|
539
|
+
* over the pane's place in its window (`CocoaPaneHost.popUpMenu`).
|
|
540
|
+
*/
|
|
541
|
+
popUpMenu(wnd, spec, onAnswer) {
|
|
542
|
+
let answered = false;
|
|
543
|
+
const answer = (id) => {
|
|
544
|
+
if (answered) return;
|
|
545
|
+
answered = true;
|
|
546
|
+
onAnswer(id ?? null);
|
|
547
|
+
this._afterInput();
|
|
548
|
+
};
|
|
549
|
+
if (this._paneMode) return this._paneMenu(wnd, spec, answer);
|
|
550
|
+
let cancelled = false;
|
|
551
|
+
let handle = null;
|
|
552
|
+
queueMicrotask(() => {
|
|
553
|
+
if (cancelled || this._closed || !wnd || wnd.destroyed) {
|
|
554
|
+
answer(null);
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const s = wnd.scale ?? this.scale;
|
|
558
|
+
handle = this._native.popUpMenu(
|
|
559
|
+
wnd._h,
|
|
560
|
+
{ ...spec, frame: spec.frame.map((v) => v / s) },
|
|
561
|
+
(id) => answer(id),
|
|
562
|
+
);
|
|
563
|
+
});
|
|
564
|
+
return () => {
|
|
565
|
+
if (answered) return;
|
|
566
|
+
cancelled = true;
|
|
567
|
+
if (handle) this._native.cancelPopUpMenu?.(handle);
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** A pane's menu, asked of the host: `pane-menu` with the frame in the
|
|
572
|
+
* window it is in, `pane-menu-answer` back. */
|
|
573
|
+
_paneMenu(wnd, spec, answer) {
|
|
574
|
+
const menu = (this._paneMenuSeq = (this._paneMenuSeq ?? 0) + 1);
|
|
575
|
+
(this._paneMenus ??= new Map()).set(menu, answer);
|
|
576
|
+
this._paneSend?.({
|
|
577
|
+
type: 'pane-menu',
|
|
578
|
+
menu,
|
|
579
|
+
// the pane's own window is the one the host knows as the pane
|
|
580
|
+
...(wnd && wnd !== this._paneWindow && { window: wnd.windowId }),
|
|
581
|
+
spec,
|
|
582
|
+
});
|
|
583
|
+
return () => {
|
|
584
|
+
if (this._paneMenus?.has(menu)) {
|
|
585
|
+
this._paneSend?.({ type: 'pane-menu-cancel', menu });
|
|
586
|
+
}
|
|
587
|
+
};
|
|
494
588
|
}
|
|
495
589
|
|
|
496
590
|
/**
|
|
@@ -780,18 +874,72 @@ export class CocoaApp {
|
|
|
780
874
|
}
|
|
781
875
|
};
|
|
782
876
|
channel.onMessage((msg) => {
|
|
783
|
-
const
|
|
784
|
-
if (!
|
|
877
|
+
const pane = this._paneWindow ?? [...this._windows.values()][0];
|
|
878
|
+
if (!pane) return;
|
|
785
879
|
if (msg?.type === 'pane-rect') {
|
|
786
|
-
|
|
880
|
+
pane.setPaneSize(msg.width, msg.height, msg.scale, msg.screen);
|
|
881
|
+
this._flushPaneOutbox();
|
|
787
882
|
this._afterInput();
|
|
883
|
+
} else if (msg?.type === 'pane-menu-answer') {
|
|
884
|
+
const answer = this._paneMenus?.get(msg.menu);
|
|
885
|
+
this._paneMenus?.delete(msg.menu);
|
|
886
|
+
answer?.(msg.id ?? null);
|
|
788
887
|
} else if (msg?.type === 'pane-event') {
|
|
789
|
-
|
|
888
|
+
// input on one of the windows the host shows for this pane names
|
|
889
|
+
// it; the pane's own is the one that names none
|
|
890
|
+
const wnd = msg.window != null ? this._windows.get(msg.window) : pane;
|
|
891
|
+
if (!wnd || wnd.destroyed) return;
|
|
892
|
+
if (msg.name === 'resize') wnd._hostGeometry?.(msg.ev);
|
|
893
|
+
// a close request answers with `preventDefault`, which did not
|
|
894
|
+
// survive the channel
|
|
895
|
+
else if (msg.name === 'close')
|
|
896
|
+
wnd.emit('close', { ...msg.ev, preventDefault() {} });
|
|
897
|
+
else wnd.emit(msg.name, msg.ev);
|
|
790
898
|
this._afterInput();
|
|
791
899
|
}
|
|
792
900
|
});
|
|
793
901
|
}
|
|
794
902
|
|
|
903
|
+
/**
|
|
904
|
+
* A message about one of the pane's other windows (`CocoaPaneSubwindow`),
|
|
905
|
+
* held until the host's first pane-rect. The host hears a pane's messages
|
|
906
|
+
* from the moment it has a `<Frame>`, and knows what to do with these
|
|
907
|
+
* only once the pane is laid out in it — a menu or a dialog open in the
|
|
908
|
+
* pane's first commit was made, and announced to nobody. That pane-rect
|
|
909
|
+
* is also where the pane learns where it is on the screen, so a popup
|
|
910
|
+
* anchored before it is placed again before anyone shows it.
|
|
911
|
+
*/
|
|
912
|
+
_postWindow(msg) {
|
|
913
|
+
if (this._paneOutbox) this._paneOutbox.push(msg);
|
|
914
|
+
else this._paneSend?.(msg);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The host is listening, and the pane knows where it is — `setPaneSize`
|
|
919
|
+
* has just moved what is anchored to it. Everything held goes out in
|
|
920
|
+
* order, but a window is announced where it is now: a held move or resize
|
|
921
|
+
* is where it was before, and sent in order it came after a present the
|
|
922
|
+
* host showed at the old place.
|
|
923
|
+
*/
|
|
924
|
+
_flushPaneOutbox() {
|
|
925
|
+
const held = this._paneOutbox;
|
|
926
|
+
if (!held) return;
|
|
927
|
+
this._paneOutbox = null;
|
|
928
|
+
for (const msg of held) {
|
|
929
|
+
if (msg.type === 'pane-window') {
|
|
930
|
+
if (msg.op === 'move' || msg.op === 'resize') continue;
|
|
931
|
+
const wnd = msg.op === 'create' && this._windows.get(msg.window);
|
|
932
|
+
if (wnd) {
|
|
933
|
+
msg.x = wnd.x;
|
|
934
|
+
msg.y = wnd.y;
|
|
935
|
+
msg.width = wnd.width;
|
|
936
|
+
msg.height = wnd.height;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
this._paneSend(msg);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
795
943
|
_unregisterWindow(wnd) {
|
|
796
944
|
this._windows.delete(wnd._key);
|
|
797
945
|
if (this._grabWindow === wnd) this._grabWindow = null;
|
package/src/cocoa/panehost.js
CHANGED
|
@@ -3,9 +3,16 @@
|
|
|
3
3
|
// contents are whatever IOSurface the pane process last presented. The pane
|
|
4
4
|
// owns its buffers and its drawing; this side owns the layer, the layout
|
|
5
5
|
// and the input — CPU offloading, not isolation (docs/frame.md).
|
|
6
|
+
//
|
|
7
|
+
// And the windows the pane makes besides its own — a menu, a dropdown's
|
|
8
|
+
// sheet, a tooltip, a dialog. A pane process runs no AppKit, so each is an
|
|
9
|
+
// NSWindow made here, showing the ring the pane draws it into the same way
|
|
10
|
+
// (`CocoaPaneSubwindow`, src/cocoa/panewindow.js), with its input sent back.
|
|
6
11
|
import { withoutActions } from './quiet.js';
|
|
7
12
|
|
|
8
|
-
|
|
13
|
+
/** One shared surface on glass: a sublayer of a window's root layer, its
|
|
14
|
+
* contents the IOSurface the pane last presented into it. */
|
|
15
|
+
class PaneLayer {
|
|
9
16
|
constructor(app, wnd) {
|
|
10
17
|
this.app = app;
|
|
11
18
|
this.wnd = wnd;
|
|
@@ -67,12 +74,14 @@ export class CocoaPaneHost {
|
|
|
67
74
|
* error is the bug it says it is.
|
|
68
75
|
*/
|
|
69
76
|
present(iosurfaceId) {
|
|
70
|
-
if (this.destroyed) return;
|
|
77
|
+
if (this.destroyed) return false;
|
|
71
78
|
try {
|
|
72
79
|
this._native.setLayerContentsIOSurface(this.layer, iosurfaceId);
|
|
73
80
|
} catch (err) {
|
|
74
81
|
if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
|
|
82
|
+
return false;
|
|
75
83
|
}
|
|
84
|
+
return true;
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
destroy() {
|
|
@@ -83,3 +92,218 @@ export class CocoaPaneHost {
|
|
|
83
92
|
);
|
|
84
93
|
}
|
|
85
94
|
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The input a window shows for the pane hands back, by the names its
|
|
98
|
+
* events go by (`CocoaApp._route`). A managed window's moves, resizes and
|
|
99
|
+
* close button go back as well (`_hostGeometry`).
|
|
100
|
+
*/
|
|
101
|
+
const FORWARDED = [
|
|
102
|
+
'mousedown',
|
|
103
|
+
'mouseup',
|
|
104
|
+
'mousemove',
|
|
105
|
+
'mouseout',
|
|
106
|
+
'wheel',
|
|
107
|
+
'keydown',
|
|
108
|
+
'keyup',
|
|
109
|
+
'focus',
|
|
110
|
+
'blur',
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
/** An event as data: the channel carries structured clones, and a
|
|
114
|
+
* `preventDefault` does not clone. */
|
|
115
|
+
function plain(ev) {
|
|
116
|
+
const out = {};
|
|
117
|
+
for (const key in ev) {
|
|
118
|
+
const value = ev[key];
|
|
119
|
+
if (value === null || typeof value !== 'object') {
|
|
120
|
+
if (typeof value !== 'function') out[key] = value;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export class CocoaPaneHost extends PaneLayer {
|
|
127
|
+
/**
|
|
128
|
+
* `send` is the pane's channel (`<Frame>`'s session): what the windows
|
|
129
|
+
* shown here for the pane hear goes back down it.
|
|
130
|
+
*/
|
|
131
|
+
constructor(app, wnd, { send } = {}) {
|
|
132
|
+
super(app, wnd);
|
|
133
|
+
this._send = send ?? null;
|
|
134
|
+
/** @type {Map<number, object>} the pane's windows, by its id for them */
|
|
135
|
+
this._windows = new Map();
|
|
136
|
+
/** @type {Map<number, () => void>} the pane's open menus, by its id */
|
|
137
|
+
this._menus = new Map();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A `pane-menu` message: the pane's `<Select>` asked for the platform's
|
|
142
|
+
* menu, which a process with no AppKit cannot drop (`CocoaApp.popUpMenu`).
|
|
143
|
+
* Its frame is in the window it is in — the pane's own, laid out at this
|
|
144
|
+
* layer's rect in the host's window, or one the pane made, shown here —
|
|
145
|
+
* and the answer goes back as `pane-menu-answer`.
|
|
146
|
+
*/
|
|
147
|
+
popUpMenu(msg) {
|
|
148
|
+
if (this.destroyed || !msg.spec) return;
|
|
149
|
+
const entry = msg.window != null ? this._windows.get(msg.window) : null;
|
|
150
|
+
const [x, y, width, height] = msg.spec.frame;
|
|
151
|
+
const at = entry ? { x: 0, y: 0 } : (this._rect ?? { x: 0, y: 0 });
|
|
152
|
+
const cancel = this.app.popUpMenu(
|
|
153
|
+
entry ? entry.wnd : this.wnd,
|
|
154
|
+
{ ...msg.spec, frame: [x + at.x, y + at.y, width, height] },
|
|
155
|
+
(id) => {
|
|
156
|
+
this._menus.delete(msg.menu);
|
|
157
|
+
this._send?.({ type: 'pane-menu-answer', menu: msg.menu, id });
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
this._menus.set(msg.menu, cancel);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `pane-menu-cancel`: the pane's `<Select>` went, or closed it. */
|
|
164
|
+
cancelMenu(menu) {
|
|
165
|
+
this._menus.get(menu)?.();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A `pane-window` message: one of the pane's other windows was made,
|
|
170
|
+
* mapped, moved, grabbed with or destroyed. `x`/`y` are the screen
|
|
171
|
+
* position the pane worked out — its anchoring ran against the origin
|
|
172
|
+
* this side sent it — so the window goes where it says.
|
|
173
|
+
*/
|
|
174
|
+
paneWindow(msg) {
|
|
175
|
+
if (this.destroyed) return;
|
|
176
|
+
if (msg.op === 'create') {
|
|
177
|
+
this._create(msg);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const entry = this._windows.get(msg.window);
|
|
181
|
+
if (!entry) return;
|
|
182
|
+
const { wnd } = entry;
|
|
183
|
+
switch (msg.op) {
|
|
184
|
+
case 'map':
|
|
185
|
+
// on the first frame rather than now: a window mapped before the
|
|
186
|
+
// pane has drawn it is a window of nothing, for a frame or two
|
|
187
|
+
if (entry.presented) wnd.map();
|
|
188
|
+
else entry.mapOnPresent = true;
|
|
189
|
+
break;
|
|
190
|
+
case 'unmap':
|
|
191
|
+
entry.mapOnPresent = false;
|
|
192
|
+
wnd.unmap();
|
|
193
|
+
break;
|
|
194
|
+
case 'move':
|
|
195
|
+
wnd.move(msg.x, msg.y);
|
|
196
|
+
break;
|
|
197
|
+
case 'resize':
|
|
198
|
+
// the layer follows on the present drawn at the new size
|
|
199
|
+
wnd.resize(msg.width, msg.height);
|
|
200
|
+
break;
|
|
201
|
+
case 'grab':
|
|
202
|
+
wnd.grabPointer();
|
|
203
|
+
break;
|
|
204
|
+
case 'ungrab':
|
|
205
|
+
wnd.ungrabPointer();
|
|
206
|
+
break;
|
|
207
|
+
case 'title':
|
|
208
|
+
wnd.setTitle(msg.title);
|
|
209
|
+
break;
|
|
210
|
+
case 'size-hints':
|
|
211
|
+
wnd.setSizeHints(msg.hints);
|
|
212
|
+
break;
|
|
213
|
+
case 'destroy':
|
|
214
|
+
this._drop(entry);
|
|
215
|
+
break;
|
|
216
|
+
default:
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** A pane-present naming one of the pane's other windows. */
|
|
222
|
+
presentWindow(msg) {
|
|
223
|
+
const entry = this._windows.get(msg.window);
|
|
224
|
+
if (!entry || this.destroyed) return;
|
|
225
|
+
entry.layer.setRect({ x: 0, y: 0, width: msg.width, height: msg.height });
|
|
226
|
+
if (!entry.layer.present(msg.id)) return;
|
|
227
|
+
entry.presented = true;
|
|
228
|
+
// a transparent window's shadow is the shape of what it shows
|
|
229
|
+
entry.wnd._shadowAfterFlip?.();
|
|
230
|
+
if (entry.mapOnPresent) {
|
|
231
|
+
entry.mapOnPresent = false;
|
|
232
|
+
entry.wnd.map();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The cursor a tree names for one of the pane's other windows: the
|
|
237
|
+
* pointer is over the window shown here, so this side sets it. */
|
|
238
|
+
windowCursor(msg) {
|
|
239
|
+
this._windows.get(msg.window)?.wnd.setCursor(msg.cursor ?? 'default');
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
_create(msg) {
|
|
243
|
+
if (this._windows.has(msg.window)) return;
|
|
244
|
+
const wnd = this.app.createWindow({
|
|
245
|
+
overrideRedirect: msg.popup,
|
|
246
|
+
x: msg.x,
|
|
247
|
+
y: msg.y,
|
|
248
|
+
width: msg.width,
|
|
249
|
+
height: msg.height,
|
|
250
|
+
transparent: msg.transparent,
|
|
251
|
+
grabKeyboard: msg.grabKeyboard,
|
|
252
|
+
dragPreview: msg.dragPreview,
|
|
253
|
+
title: msg.title,
|
|
254
|
+
decorations: msg.decorations,
|
|
255
|
+
resizable: msg.resizable,
|
|
256
|
+
sizeHints: msg.sizeHints,
|
|
257
|
+
});
|
|
258
|
+
const entry = {
|
|
259
|
+
id: msg.window,
|
|
260
|
+
wnd,
|
|
261
|
+
layer: new PaneLayer(this.app, wnd),
|
|
262
|
+
presented: false,
|
|
263
|
+
mapOnPresent: false,
|
|
264
|
+
};
|
|
265
|
+
this._windows.set(msg.window, entry);
|
|
266
|
+
const forward = (name, ev) => {
|
|
267
|
+
if (this.destroyed || entry.dropped) return;
|
|
268
|
+
this._send?.({ type: 'pane-event', window: entry.id, name, ev });
|
|
269
|
+
};
|
|
270
|
+
for (const name of FORWARDED) {
|
|
271
|
+
wnd.on(name, (ev) => forward(name, plain(ev)));
|
|
272
|
+
}
|
|
273
|
+
// A popup goes where the pane puts it and no one else moves it. A
|
|
274
|
+
// managed window is the user's to move, resize and close as well, and
|
|
275
|
+
// the pane hears of each — of a resize only the user's, since the
|
|
276
|
+
// pane's own come back as AppKit's echo of the frame it was asked for.
|
|
277
|
+
if (!msg.popup) {
|
|
278
|
+
wnd.on('resize', (ev) =>
|
|
279
|
+
forward('resize', {
|
|
280
|
+
x: ev.x,
|
|
281
|
+
y: ev.y,
|
|
282
|
+
width: ev.width,
|
|
283
|
+
height: ev.height,
|
|
284
|
+
resized: Boolean(ev.resized && wnd.liveResizing),
|
|
285
|
+
}),
|
|
286
|
+
);
|
|
287
|
+
wnd.on('close', () => forward('close', {}));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
_drop(entry) {
|
|
292
|
+
if (entry.dropped) return;
|
|
293
|
+
entry.dropped = true;
|
|
294
|
+
this._windows.delete(entry.id);
|
|
295
|
+
entry.wnd.ungrabPointer();
|
|
296
|
+
entry.layer.destroy();
|
|
297
|
+
entry.wnd.destroy();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The pane is going, and every window it had here goes with it — a pane
|
|
301
|
+
* that crashed with a menu open leaves no menu behind. */
|
|
302
|
+
destroy() {
|
|
303
|
+
if (this.destroyed) return;
|
|
304
|
+
for (const cancel of [...this._menus.values()]) cancel();
|
|
305
|
+
this._menus.clear();
|
|
306
|
+
for (const entry of [...this._windows.values()]) this._drop(entry);
|
|
307
|
+
super.destroy();
|
|
308
|
+
}
|
|
309
|
+
}
|
package/src/cocoa/panewindow.js
CHANGED
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
// via emit. Geometry and input arrive as channel messages (the host owns
|
|
15
15
|
// layout and hit-testing — this is CPU offloading, not isolation), and what
|
|
16
16
|
// goes out is pane-present, and pane-cursor for the cursor the host shows.
|
|
17
|
+
//
|
|
18
|
+
// Every other window the pane's tree makes — a menu, a dropdown's sheet, a
|
|
19
|
+
// tooltip, a dialog — is a `CocoaPaneSubwindow`: the same ring and the same
|
|
20
|
+
// presents, naming the window, and an NSWindow the host makes for it
|
|
21
|
+
// (src/cocoa/panehost.js), because nothing in a pane process runs AppKit.
|
|
17
22
|
import { BackendContext2D } from '../backend/context2d.js';
|
|
18
23
|
|
|
19
24
|
let nextPaneId = 1;
|
|
@@ -31,13 +36,15 @@ export class CocoaPaneWindow {
|
|
|
31
36
|
this.id = this.windowId;
|
|
32
37
|
this.windowNumber = this.windowId;
|
|
33
38
|
this.scale = app.scale ?? 2;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
// Device pixels, as every window's attributes are (`windowAttributes`
|
|
40
|
+
// multiplies the props through once): the size the pane is born at,
|
|
41
|
+
// until the host's first pane-rect says what it is. Multiplied again
|
|
42
|
+
// here, a pane was born twice its size, and a dialog it opened in its
|
|
43
|
+
// first commit was centred over a window that was never there.
|
|
44
|
+
this.width = Math.max(1, Math.round(attributes.width ?? 400 * this.scale));
|
|
38
45
|
this.height = Math.max(
|
|
39
46
|
1,
|
|
40
|
-
Math.round(
|
|
47
|
+
Math.round(attributes.height ?? 300 * this.scale),
|
|
41
48
|
);
|
|
42
49
|
this._listeners = new Map();
|
|
43
50
|
this._surface = null;
|
|
@@ -52,27 +59,51 @@ export class CocoaPaneWindow {
|
|
|
52
59
|
this._seq = 0;
|
|
53
60
|
this._presentedAt = -Infinity;
|
|
54
61
|
this._reactX11Node = null;
|
|
62
|
+
// Where the pane's corner is on the screen, in device pixels, as the
|
|
63
|
+
// host last said (`setPaneSize`): what a popup the pane anchors to one
|
|
64
|
+
// of its nodes is placed against (`windowOrigin`, src/anchor.js). The
|
|
65
|
+
// pane has no window of its own to ask, so until the host says, a menu
|
|
66
|
+
// dropped from the screen's corner.
|
|
67
|
+
this.x = 0;
|
|
68
|
+
this.y = 0;
|
|
69
|
+
this._screenOrigin = { x: 0, y: 0 };
|
|
70
|
+
// what the app's window map keys this window by, and the id the host
|
|
71
|
+
// names it by in a pane-event (`CocoaApp.attachPaneChannel`)
|
|
72
|
+
this._key = this.windowId;
|
|
55
73
|
app._registerWindow(this);
|
|
56
74
|
}
|
|
57
75
|
|
|
58
76
|
// --- the channel-facing half --------------------------------------------
|
|
59
77
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
78
|
+
/**
|
|
79
|
+
* The host's layout answer: logical size plus the display scale, and
|
|
80
|
+
* where the pane is on the screen — `screen`, device pixels, absent from
|
|
81
|
+
* a host that does not say. A move alone is a `resize` that `moved`, the
|
|
82
|
+
* event a window's own move is, so what is anchored here follows it.
|
|
83
|
+
*/
|
|
84
|
+
setPaneSize(width, height, scale, screen) {
|
|
62
85
|
if (this.destroyed) return;
|
|
63
86
|
if (scale) this.scale = scale;
|
|
64
87
|
const w = Math.max(1, Math.round(width * this.scale));
|
|
65
88
|
const h = Math.max(1, Math.round(height * this.scale));
|
|
66
|
-
|
|
89
|
+
const moved =
|
|
90
|
+
screen != null && (screen.x !== this.x || screen.y !== this.y);
|
|
91
|
+
const resized = w !== this.width || h !== this.height;
|
|
92
|
+
if (!moved && !resized) return;
|
|
93
|
+
if (moved) {
|
|
94
|
+
this.x = screen.x;
|
|
95
|
+
this.y = screen.y;
|
|
96
|
+
this._screenOrigin = { x: screen.x, y: screen.y };
|
|
97
|
+
}
|
|
67
98
|
this.width = w;
|
|
68
99
|
this.height = h;
|
|
69
100
|
this.emit('resize', {
|
|
70
101
|
width: w,
|
|
71
102
|
height: h,
|
|
72
|
-
x:
|
|
73
|
-
y:
|
|
74
|
-
moved
|
|
75
|
-
resized
|
|
103
|
+
x: this.x,
|
|
104
|
+
y: this.y,
|
|
105
|
+
moved,
|
|
106
|
+
resized,
|
|
76
107
|
});
|
|
77
108
|
}
|
|
78
109
|
|
|
@@ -103,7 +134,13 @@ export class CocoaPaneWindow {
|
|
|
103
134
|
*/
|
|
104
135
|
setCursor(name) {
|
|
105
136
|
if (this.destroyed) return;
|
|
106
|
-
this.
|
|
137
|
+
this._post({ type: 'pane-cursor', cursor: name ?? null });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A message about this window, to the host. The pane's own names none:
|
|
141
|
+
* the host knows it as the pane (`CocoaPaneSubwindow` names itself). */
|
|
142
|
+
_post(msg) {
|
|
143
|
+
this.app._paneSend?.(msg);
|
|
107
144
|
}
|
|
108
145
|
|
|
109
146
|
requestAnimationFrame(cb) {
|
|
@@ -264,7 +301,7 @@ export class CocoaPaneWindow {
|
|
|
264
301
|
const shown = this._ring[this._drawIndex];
|
|
265
302
|
this._native.surfaceUnlock(shown.handle);
|
|
266
303
|
this._flushDamage = null;
|
|
267
|
-
this.
|
|
304
|
+
this._post({
|
|
268
305
|
type: 'pane-present',
|
|
269
306
|
seq: ++this._seq,
|
|
270
307
|
id: shown.iosurfaceId,
|
|
@@ -304,3 +341,156 @@ export class CocoaPaneWindow {
|
|
|
304
341
|
this._releaseRing();
|
|
305
342
|
}
|
|
306
343
|
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Any other window a pane's tree makes: a `<popup>` — a menu, a dropdown's
|
|
347
|
+
* sheet, a tooltip, the edit menu — or a managed one, a dialog (#824).
|
|
348
|
+
*
|
|
349
|
+
* A pane process runs no AppKit (`CocoaApp.start`): no NSApplication, no
|
|
350
|
+
* event pump. An NSWindow made here exists and is never shown, so a
|
|
351
|
+
* `<Select>` in a pane took the press, opened, and showed nothing. This
|
|
352
|
+
* window paints into a shared ring exactly as the pane does, and the host
|
|
353
|
+
* makes the NSWindow and shows the ring in it (`CocoaPaneHost`,
|
|
354
|
+
* src/cocoa/panehost.js) — the split the pane already has: the pane draws,
|
|
355
|
+
* the host owns glass and input.
|
|
356
|
+
*
|
|
357
|
+
* Its place is the pane's to decide and the host's to take as given: `x`
|
|
358
|
+
* and `y` are the screen position the pane's own anchoring worked out,
|
|
359
|
+
* against the origin the host sends with every pane-rect. What goes out is
|
|
360
|
+
* the window's life as `pane-window` messages and its frames as
|
|
361
|
+
* `pane-present`s naming it; what comes back is the input on the host's
|
|
362
|
+
* window, as `pane-event`s naming it, and for a managed window the moves
|
|
363
|
+
* and the resizes the user made and the close button.
|
|
364
|
+
*/
|
|
365
|
+
export class CocoaPaneSubwindow extends CocoaPaneWindow {
|
|
366
|
+
constructor(app, attributes = {}) {
|
|
367
|
+
super(app, attributes);
|
|
368
|
+
this.attributes = attributes;
|
|
369
|
+
this._popup = attributes.overrideRedirect === true;
|
|
370
|
+
this.mapped = false;
|
|
371
|
+
// device pixels, as every window's attributes are — unlike the pane's
|
|
372
|
+
// own, whose size is the host's to say
|
|
373
|
+
const size = this.snapSize(attributes.width ?? 1, attributes.height ?? 1);
|
|
374
|
+
this.width = size.width;
|
|
375
|
+
this.height = size.height;
|
|
376
|
+
this.x = Math.round(attributes.x ?? 0);
|
|
377
|
+
this.y = Math.round(attributes.y ?? 0);
|
|
378
|
+
this._screenOrigin = { x: this.x, y: this.y };
|
|
379
|
+
this._send({
|
|
380
|
+
op: 'create',
|
|
381
|
+
x: this.x,
|
|
382
|
+
y: this.y,
|
|
383
|
+
width: this.width,
|
|
384
|
+
height: this.height,
|
|
385
|
+
popup: this._popup,
|
|
386
|
+
// an ARGB visual is a transparent window here, as in `CocoaWindow`
|
|
387
|
+
transparent:
|
|
388
|
+
attributes.visual !== undefined || Boolean(attributes.transparent),
|
|
389
|
+
grabKeyboard: attributes.grabKeyboard === true,
|
|
390
|
+
dragPreview: Boolean(attributes.dragPreview),
|
|
391
|
+
title: attributes.title ?? '',
|
|
392
|
+
decorations: attributes.decorations,
|
|
393
|
+
resizable: attributes.resizable,
|
|
394
|
+
sizeHints: attributes.sizeHints,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Named, and held until the host is listening (`CocoaApp._postWindow`). */
|
|
399
|
+
_post(msg) {
|
|
400
|
+
this.app._postWindow({ ...msg, window: this.windowId });
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
_send(msg) {
|
|
404
|
+
if (this.destroyed) return;
|
|
405
|
+
this._post({ type: 'pane-window', ...msg });
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** `CocoaWindow`'s rule, so the size this window records is the size the
|
|
409
|
+
* host's NSWindow will take: whole points, rounded up. */
|
|
410
|
+
snapSize(width, height) {
|
|
411
|
+
const s = this.scale;
|
|
412
|
+
const up = (v) =>
|
|
413
|
+
Math.max(1, Math.ceil(Math.max(1, Math.round(v)) / s) * s);
|
|
414
|
+
return { width: up(width), height: up(height) };
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
map() {
|
|
418
|
+
this.mapped = true;
|
|
419
|
+
this._send({ op: 'map' });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
unmap() {
|
|
423
|
+
this.mapped = false;
|
|
424
|
+
this._send({ op: 'unmap' });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
move(x, y) {
|
|
428
|
+
this.x = Math.round(x);
|
|
429
|
+
this.y = Math.round(y);
|
|
430
|
+
this._screenOrigin = { x: this.x, y: this.y };
|
|
431
|
+
this._send({ op: 'move', x: this.x, y: this.y });
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
resize(width, height) {
|
|
435
|
+
const size = this.snapSize(width, height);
|
|
436
|
+
this.width = size.width;
|
|
437
|
+
this.height = size.height;
|
|
438
|
+
this._send({ op: 'resize', width: this.width, height: this.height });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
setTitle(title) {
|
|
442
|
+
this._send({ op: 'title', title: String(title ?? '') });
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
setSizeHints(hints = {}) {
|
|
446
|
+
this._send({ op: 'size-hints', hints: { ...hints } });
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** The grab is the host's: a press anywhere in its windows comes here,
|
|
450
|
+
* outside this window, which is the dismissal (`CocoaApp._grabTarget`). */
|
|
451
|
+
grabPointer(options, cb) {
|
|
452
|
+
this._send({ op: 'grab' });
|
|
453
|
+
cb?.(null, 0);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
ungrabPointer() {
|
|
457
|
+
this._send({ op: 'ungrab' });
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Where the host's window went for a reason of its own — the user moved a
|
|
462
|
+
* dialog, or resized it — which a `resize` here says as a window's own
|
|
463
|
+
* move or resize does. A size comes only from the user (`resized`): the
|
|
464
|
+
* pane's own resizes come back as the host's echo, and one taken as news
|
|
465
|
+
* would read as the user taking an `'auto'` window over.
|
|
466
|
+
*/
|
|
467
|
+
_hostGeometry(ev) {
|
|
468
|
+
if (this.destroyed) return;
|
|
469
|
+
const moved = ev.x !== this.x || ev.y !== this.y;
|
|
470
|
+
const resized =
|
|
471
|
+
Boolean(ev.resized) &&
|
|
472
|
+
(ev.width !== this.width || ev.height !== this.height);
|
|
473
|
+
if (!moved && !resized) return;
|
|
474
|
+
this.x = ev.x;
|
|
475
|
+
this.y = ev.y;
|
|
476
|
+
this._screenOrigin = { x: ev.x, y: ev.y };
|
|
477
|
+
if (resized) {
|
|
478
|
+
this.width = ev.width;
|
|
479
|
+
this.height = ev.height;
|
|
480
|
+
}
|
|
481
|
+
this.emit('resize', {
|
|
482
|
+
width: this.width,
|
|
483
|
+
height: this.height,
|
|
484
|
+
x: this.x,
|
|
485
|
+
y: this.y,
|
|
486
|
+
moved,
|
|
487
|
+
resized,
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
destroy() {
|
|
492
|
+
if (this.destroyed) return;
|
|
493
|
+
this._send({ op: 'destroy' });
|
|
494
|
+
super.destroy();
|
|
495
|
+
}
|
|
496
|
+
}
|
package/src/cocoa/promotion.js
CHANGED
|
@@ -591,8 +591,16 @@ export class CocoaPromotion {
|
|
|
591
591
|
* node's reach, the parent's clip, if it has one, must hold the whole of
|
|
592
592
|
* it, and the parent must not fade: its group is drawn on the bitmap or on
|
|
593
593
|
* its own layer, and either way a layer of the node's would be outside it.
|
|
594
|
+
*
|
|
595
|
+
* With `cut`, the reach is an element's part (src/cocoa/sprites.js),
|
|
596
|
+
* whose layer can be cut to a rectangle: a clipping ancestor with square
|
|
597
|
+
* corners narrows `cut.clip` to its padding box — its border is drawn
|
|
598
|
+
* over what it holds anyway — instead of having to hold the part whole,
|
|
599
|
+
* and every test from there up is asked of what shows through the clip.
|
|
600
|
+
* `cut.bounds` comes back as that. A clip that leaves nothing showing is
|
|
601
|
+
* a refusal.
|
|
594
602
|
*/
|
|
595
|
-
_clear(node, bounds = node._subtreeBounds()) {
|
|
603
|
+
_clear(node, bounds = node._subtreeBounds(), cut = null) {
|
|
596
604
|
// the exact reach, not `paintBounds()`: that one carries the damage
|
|
597
605
|
// model's pixel of slop, and a section laid out flush under a card
|
|
598
606
|
// would read as reaching into it. An element's part is asked about at
|
|
@@ -600,6 +608,17 @@ export class CocoaPromotion {
|
|
|
600
608
|
for (let n = node; !n.isWindow; n = n.parent) {
|
|
601
609
|
const parent = n.parent;
|
|
602
610
|
if (!parent) return false;
|
|
611
|
+
if (
|
|
612
|
+
cut &&
|
|
613
|
+
!parent.isWindow &&
|
|
614
|
+
parent.clipsChildren?.() &&
|
|
615
|
+
!(parent.style?.borderRadius > 0)
|
|
616
|
+
) {
|
|
617
|
+
const inner = insetRect(parent.abs, borderReach(parent));
|
|
618
|
+
cut.clip = cut.clip ? intersectRects(cut.clip, inner) : inner;
|
|
619
|
+
bounds = cut.clip && intersectRects(bounds, cut.clip);
|
|
620
|
+
if (!bounds) return false;
|
|
621
|
+
}
|
|
603
622
|
const order = parent.paintOrder();
|
|
604
623
|
for (let j = order.indexOf(n) + 1; j < order.length; j++) {
|
|
605
624
|
if (this._reaches(order[j], bounds)) return false;
|
|
@@ -636,6 +655,7 @@ export class CocoaPromotion {
|
|
|
636
655
|
}
|
|
637
656
|
}
|
|
638
657
|
}
|
|
658
|
+
if (cut) cut.bounds = bounds;
|
|
639
659
|
return true;
|
|
640
660
|
}
|
|
641
661
|
|
package/src/cocoa/sprites.js
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
// element.
|
|
26
26
|
|
|
27
27
|
import { RASTER_PAD, RasterState } from './presenter.js';
|
|
28
|
+
import { intersectRects } from '../nodes/rects.js';
|
|
28
29
|
import { DEV } from '../nodes/util.js';
|
|
29
30
|
|
|
30
31
|
// Past this many device pixels on a side a part is drawn by its element: a
|
|
@@ -109,6 +110,7 @@ function isSprite(sprite) {
|
|
|
109
110
|
if (sprite == null || typeof sprite.key !== 'string') return false;
|
|
110
111
|
if (!isRect(sprite.rect) || typeof sprite.paint !== 'function') return false;
|
|
111
112
|
if (sprite.reach !== undefined && !isRect(sprite.reach)) return false;
|
|
113
|
+
if (sprite.clip !== undefined && !isRect(sprite.clip)) return false;
|
|
112
114
|
if (sprite.opacity !== undefined && !finite(sprite.opacity)) return false;
|
|
113
115
|
if (sprite.transform !== undefined && !isMatrix(sprite.transform)) {
|
|
114
116
|
return false;
|
|
@@ -304,18 +306,30 @@ export class SpriteLayers {
|
|
|
304
306
|
if (now.has(sprite.key)) continue;
|
|
305
307
|
const raster = rasterRectOf(sprite);
|
|
306
308
|
if (raster.width > MAX_SIDE || raster.height > MAX_SIDE) continue;
|
|
309
|
+
// what shows of everywhere it can be: inside its own clip, and the
|
|
310
|
+
// clips of its element's ancestors, which cut its layer (`_clear`)
|
|
311
|
+
const cut = { clip: sprite.clip ?? null, bounds: null };
|
|
307
312
|
const extent = extentOf(sprite);
|
|
308
|
-
|
|
313
|
+
const bounds = cut.clip ? intersectRects(extent, cut.clip) : extent;
|
|
314
|
+
if (!bounds || !this.promotion._clear(host, bounds, cut)) continue;
|
|
315
|
+
const shown = cut.bounds;
|
|
309
316
|
let state = was?.get(sprite.key);
|
|
317
|
+
// a clip that comes or goes is a layer in a box or out of one: lifted
|
|
318
|
+
// again, rather than a running layer moved between parents
|
|
319
|
+
if (state && Boolean(state.clip) !== Boolean(cut.clip)) {
|
|
320
|
+
this._drop(state, root);
|
|
321
|
+
state = null;
|
|
322
|
+
}
|
|
310
323
|
const lifting = !state;
|
|
311
|
-
if (lifting) state = this._lift(host, sprite.key);
|
|
324
|
+
if (lifting) state = this._lift(host, sprite.key, cut.clip !== null);
|
|
312
325
|
state.sprite = sprite;
|
|
326
|
+
state.clip = cut.clip;
|
|
313
327
|
state.order = [...place, AFTER_CHILDREN, index];
|
|
314
|
-
// the bitmap under where it
|
|
328
|
+
// the bitmap under where it shows repaints without it, from this
|
|
315
329
|
// frame on; where it went since, it is the element's own claims
|
|
316
330
|
// that move the hole
|
|
317
|
-
if (lifting) this.promotion._claim(root,
|
|
318
|
-
state.extent =
|
|
331
|
+
if (lifting) this.promotion._claim(root, shown);
|
|
332
|
+
state.extent = shown;
|
|
319
333
|
now.set(sprite.key, state);
|
|
320
334
|
}
|
|
321
335
|
if (was) {
|
|
@@ -356,13 +370,25 @@ export class SpriteLayers {
|
|
|
356
370
|
}
|
|
357
371
|
}
|
|
358
372
|
|
|
359
|
-
_lift(host, key) {
|
|
373
|
+
_lift(host, key, clipped) {
|
|
360
374
|
const layer = this.native.createLayer();
|
|
361
|
-
|
|
375
|
+
// a part cut to a clip is in a box of the clip's size that masks to it,
|
|
376
|
+
// and the box is what stands among the layers above the bitmap
|
|
377
|
+
let box = null;
|
|
378
|
+
if (clipped) {
|
|
379
|
+
box = this.native.createLayer();
|
|
380
|
+
this.native.addSublayer(this.promotion.rootVisual.layer, box);
|
|
381
|
+
this.native.addSublayer(box, layer);
|
|
382
|
+
} else {
|
|
383
|
+
this.native.addSublayer(this.promotion.rootVisual.layer, layer);
|
|
384
|
+
}
|
|
362
385
|
return {
|
|
363
386
|
host,
|
|
364
387
|
key,
|
|
365
388
|
layer,
|
|
389
|
+
box,
|
|
390
|
+
boxProps: {},
|
|
391
|
+
clip: null, // device pixels, window coordinates
|
|
366
392
|
raster: new RasterState(),
|
|
367
393
|
painted: UNPAINTED,
|
|
368
394
|
rect: null, // the raster's rect, device pixels, window coordinates
|
|
@@ -383,7 +409,7 @@ export class SpriteLayers {
|
|
|
383
409
|
* forgotten, and the bitmap under where it could be repaints with the
|
|
384
410
|
* element drawing it again. */
|
|
385
411
|
_drop(state, root) {
|
|
386
|
-
this.native.removeFromSuperlayer(state.layer);
|
|
412
|
+
this.native.removeFromSuperlayer(state.box ?? state.layer);
|
|
387
413
|
state.raster.release(this.native);
|
|
388
414
|
for (const run of state.runs.values()) this._ends().delete(run.caId);
|
|
389
415
|
state.runs.clear();
|
|
@@ -415,14 +441,31 @@ export class SpriteLayers {
|
|
|
415
441
|
state.rect = rect;
|
|
416
442
|
const origin = originOf(sprite);
|
|
417
443
|
const m = sprite.transform ?? IDENTITY;
|
|
444
|
+
// in its box, where it is cut to a clip: placed from the clip's corner,
|
|
445
|
+
// and the box at the part's place among the layers
|
|
446
|
+
const clip = state.clip;
|
|
447
|
+
if (state.box) {
|
|
448
|
+
const frame = [clip.x / s, clip.y / s, clip.width / s, clip.height / s];
|
|
449
|
+
const was = state.boxProps;
|
|
450
|
+
if (!sameMatrix(was.frame, frame) || was.zPosition !== z) {
|
|
451
|
+
this.native.setLayerProps(state.box, {
|
|
452
|
+
frame,
|
|
453
|
+
masksToBounds: true,
|
|
454
|
+
zPosition: z,
|
|
455
|
+
});
|
|
456
|
+
state.boxProps = { frame, zPosition: z };
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
const ox = state.box ? clip.x : 0;
|
|
460
|
+
const oy = state.box ? clip.y : 0;
|
|
418
461
|
const next = {
|
|
419
462
|
bounds: [0, 0, rect.width / s, rect.height / s],
|
|
420
463
|
anchorPoint: [
|
|
421
464
|
(origin.x - rect.x) / rect.width,
|
|
422
465
|
(origin.y - rect.y) / rect.height,
|
|
423
466
|
],
|
|
424
|
-
position: [origin.x / s, origin.y / s],
|
|
425
|
-
zPosition: z,
|
|
467
|
+
position: [(origin.x - ox) / s, (origin.y - oy) / s],
|
|
468
|
+
zPosition: state.box ? 0 : z,
|
|
426
469
|
opacity: Math.min(1, Math.max(0, sprite.opacity ?? 1)),
|
|
427
470
|
matrix: m,
|
|
428
471
|
};
|
|
@@ -552,7 +595,7 @@ export class SpriteLayers {
|
|
|
552
595
|
destroy() {
|
|
553
596
|
for (const lifted of this.hosts.values()) {
|
|
554
597
|
for (const state of lifted.values()) {
|
|
555
|
-
this.native.removeFromSuperlayer(state.layer);
|
|
598
|
+
this.native.removeFromSuperlayer(state.box ?? state.layer);
|
|
556
599
|
state.raster.release(this.native);
|
|
557
600
|
for (const run of state.runs.values()) this._ends().delete(run.caId);
|
|
558
601
|
}
|
package/src/components/Select.js
CHANGED
|
@@ -143,6 +143,33 @@ const TRIGGER_PAD_LEFT = 10;
|
|
|
143
143
|
// (`nodes/scrollbars.js`, SCROLLBAR_WIDTH), so a menu that scrolls reserves the room
|
|
144
144
|
const SCROLLBAR_WIDTH = 6;
|
|
145
145
|
|
|
146
|
+
// The family a caption's style names, for the platform's menu: the first of
|
|
147
|
+
// a CSS list, unquoted, and nothing for a generic one, which is the menu
|
|
148
|
+
// font's own to answer.
|
|
149
|
+
const GENERIC_FAMILIES = new Set([
|
|
150
|
+
'sans-serif',
|
|
151
|
+
'serif',
|
|
152
|
+
'monospace',
|
|
153
|
+
'cursive',
|
|
154
|
+
'fantasy',
|
|
155
|
+
'system-ui',
|
|
156
|
+
'ui-sans-serif',
|
|
157
|
+
'ui-serif',
|
|
158
|
+
'ui-monospace',
|
|
159
|
+
'-apple-system',
|
|
160
|
+
'blinkmacsystemfont',
|
|
161
|
+
]);
|
|
162
|
+
function menuFamily(family) {
|
|
163
|
+
if (typeof family !== 'string') return undefined;
|
|
164
|
+
const first = family
|
|
165
|
+
.split(',')[0]
|
|
166
|
+
.trim()
|
|
167
|
+
.replace(/^["']|["']$/g, '');
|
|
168
|
+
return first && !GENERIC_FAMILIES.has(first.toLowerCase())
|
|
169
|
+
? first
|
|
170
|
+
: undefined;
|
|
171
|
+
}
|
|
172
|
+
|
|
146
173
|
function normalizeOption(option) {
|
|
147
174
|
return typeof option === 'object' && option !== null
|
|
148
175
|
? option
|
|
@@ -291,6 +318,17 @@ function Option({
|
|
|
291
318
|
* caption's `fontSize` is read back: the chevron is as tall as the capitals
|
|
292
319
|
* beside it. Any slot also chooses the drawn trigger over the platform's
|
|
293
320
|
* popup bezel, whose pixels no style reaches.
|
|
321
|
+
*
|
|
322
|
+
* `nativeMenu` is the menu's half of `native`: where the backend drops a
|
|
323
|
+
* control's menu as the platform's own (`app.nativePopUpMenus`, macOS), the
|
|
324
|
+
* list is that menu — the chosen row over the trigger, the platform's
|
|
325
|
+
* tracking, type-select and VoiceOver — rather than a `<popup>` drawn to
|
|
326
|
+
* look like it. It follows the trigger by default, so a select under the
|
|
327
|
+
* native bezel gets the native menu and a drawn one keeps the drawn menu;
|
|
328
|
+
* `true` asks for the platform's menu under a drawn trigger too, which is
|
|
329
|
+
* what a browser does with a `<select>` a page styled, and `false` keeps the
|
|
330
|
+
* drawn one. A drawn trigger's menu is set at the caption's size, in the
|
|
331
|
+
* family its style names when the system has it.
|
|
294
332
|
*/
|
|
295
333
|
export function Select({
|
|
296
334
|
value,
|
|
@@ -299,6 +337,7 @@ export function Select({
|
|
|
299
337
|
name,
|
|
300
338
|
placeholder = 'Select…',
|
|
301
339
|
native,
|
|
340
|
+
nativeMenu,
|
|
302
341
|
style,
|
|
303
342
|
labelStyle,
|
|
304
343
|
chevronStyle,
|
|
@@ -308,11 +347,20 @@ export function Select({
|
|
|
308
347
|
const app = useAppOrNull();
|
|
309
348
|
const styled = Boolean(labelStyle || chevronStyle);
|
|
310
349
|
const nativeControls = useNativeControls(styled ? false : native);
|
|
350
|
+
// the platform's own menu, where there is one and this select wants it
|
|
351
|
+
const platformMenu =
|
|
352
|
+
(nativeMenu ?? nativeControls) && Boolean(app?.nativePopUpMenus);
|
|
311
353
|
// The size the caption is set in, where its slot names one: the chevron is
|
|
312
354
|
// measured against those capitals, and an `<Icon>`'s size does not inherit.
|
|
313
355
|
const ownSize = flattenStyle(labelStyle).fontSize;
|
|
314
356
|
const captionSize = typeof ownSize === 'number' ? ownSize : theme.fontSize;
|
|
315
357
|
const [open, setOpen] = useState(false);
|
|
358
|
+
// The platform's menu, while it is down: its cancel function, and whether
|
|
359
|
+
// it is open, for the trigger's open look and `aria-expanded`.
|
|
360
|
+
const platformRef = useRef(null);
|
|
361
|
+
const [platformOpen, setPlatformOpen] = useState(false);
|
|
362
|
+
// a menu is down, whichever one: the trigger's open look follows it
|
|
363
|
+
const expanded = open || platformOpen;
|
|
316
364
|
const [anchor, setAnchor] = useState(null);
|
|
317
365
|
const [focused, setFocused] = useState(false);
|
|
318
366
|
const [activeIndex, setActiveIndex] = useState(-1);
|
|
@@ -387,8 +435,48 @@ export function Select({
|
|
|
387
435
|
};
|
|
388
436
|
};
|
|
389
437
|
|
|
390
|
-
const close = () =>
|
|
438
|
+
const close = () => {
|
|
439
|
+
setOpen(false);
|
|
440
|
+
platformRef.current?.();
|
|
441
|
+
};
|
|
442
|
+
// The platform's menu, dropped from the trigger: the same options, the
|
|
443
|
+
// current one placed over it. Its answer is a pick like a row's.
|
|
444
|
+
const openPlatformMenu = () => {
|
|
445
|
+
const node = triggerRef.current;
|
|
446
|
+
const wnd = node?.root?.window;
|
|
447
|
+
if (!node || !wnd || platformRef.current) return;
|
|
448
|
+
const chosen = normalized.findIndex((o) => o.value === value);
|
|
449
|
+
const family = menuFamily(flattenStyle(labelStyle).fontFamily);
|
|
450
|
+
setPlatformOpen(true);
|
|
451
|
+
platformRef.current = app.popUpMenu(
|
|
452
|
+
wnd,
|
|
453
|
+
{
|
|
454
|
+
items: normalized.map((o, i) => ({
|
|
455
|
+
id: i + 1,
|
|
456
|
+
title: String(o.label),
|
|
457
|
+
})),
|
|
458
|
+
frame: [node.abs.x, node.abs.y, node.abs.width, node.abs.height],
|
|
459
|
+
...(chosen >= 0 && { selected: chosen + 1 }),
|
|
460
|
+
// under the bezel the menu is the bezel's, at the menu font's size
|
|
461
|
+
...(!nativeControls && { fontSize: captionSize }),
|
|
462
|
+
...(!nativeControls && family && { fontFamily: family }),
|
|
463
|
+
// the theme's, as the bezel's appearance is
|
|
464
|
+
appearance: theme.scheme === 'dark' ? 'dark' : 'light',
|
|
465
|
+
rtl: node.direction === 'rtl',
|
|
466
|
+
},
|
|
467
|
+
(id) => {
|
|
468
|
+
platformRef.current = null;
|
|
469
|
+
setPlatformOpen(false);
|
|
470
|
+
const option = id != null ? normalized[id - 1] : null;
|
|
471
|
+
if (option && option.value !== value) emit(option.value);
|
|
472
|
+
},
|
|
473
|
+
);
|
|
474
|
+
};
|
|
391
475
|
const openMenu = () => {
|
|
476
|
+
if (platformMenu) {
|
|
477
|
+
openPlatformMenu();
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
392
480
|
const node = triggerRef.current;
|
|
393
481
|
if (!node) return;
|
|
394
482
|
const rect = measureAnchor(menuAnchorOptions());
|
|
@@ -398,7 +486,9 @@ export function Select({
|
|
|
398
486
|
setActiveIndex(selected >= 0 ? selected : 0);
|
|
399
487
|
setOpen(true);
|
|
400
488
|
};
|
|
401
|
-
const toggle = () => (
|
|
489
|
+
const toggle = () => (expanded ? close() : openMenu());
|
|
490
|
+
// a menu still down when the select goes is taken with it
|
|
491
|
+
useEffect(() => () => platformRef.current?.(), []);
|
|
402
492
|
|
|
403
493
|
// keeps the menu under the trigger for as long as it is open: a scrolled
|
|
404
494
|
// ancestor, the trigger's own layout moving it (a neighbouring field
|
|
@@ -491,7 +581,7 @@ export function Select({
|
|
|
491
581
|
{
|
|
492
582
|
theme,
|
|
493
583
|
role: 'combobox',
|
|
494
|
-
'aria-expanded':
|
|
584
|
+
'aria-expanded': expanded,
|
|
495
585
|
'aria-haspopup': 'listbox',
|
|
496
586
|
ref: triggerRef,
|
|
497
587
|
focusable: true,
|
|
@@ -546,7 +636,8 @@ export function Select({
|
|
|
546
636
|
paddingRight: 10,
|
|
547
637
|
borderWidth: theme.borderWidth,
|
|
548
638
|
borderRadius: theme.radius,
|
|
549
|
-
borderColor:
|
|
639
|
+
borderColor:
|
|
640
|
+
focused || expanded ? theme.borderFocus : theme.border,
|
|
550
641
|
backgroundColor: theme.surface,
|
|
551
642
|
},
|
|
552
643
|
// Hover and press belong to the trigger while it is *shut*: they say
|
|
@@ -560,7 +651,7 @@ export function Select({
|
|
|
560
651
|
// not overwrite. The only way for open to win is to not be competing.
|
|
561
652
|
// (In native mode the wash overlay below answers the press instead.)
|
|
562
653
|
!nativeControls &&
|
|
563
|
-
!
|
|
654
|
+
!expanded && {
|
|
564
655
|
':hover': { backgroundColor: theme.surfaceHover },
|
|
565
656
|
':active': { backgroundColor: theme.surfaceActive },
|
|
566
657
|
},
|
|
@@ -576,7 +667,7 @@ export function Select({
|
|
|
576
667
|
nativeControls &&
|
|
577
668
|
h(Bezel, {
|
|
578
669
|
kind: 'popup',
|
|
579
|
-
pressed:
|
|
670
|
+
pressed: expanded,
|
|
580
671
|
enabled: true,
|
|
581
672
|
style: ABS_FILL,
|
|
582
673
|
}),
|
|
@@ -605,7 +696,7 @@ export function Select({
|
|
|
605
696
|
style: [
|
|
606
697
|
ABS_FILL,
|
|
607
698
|
{ borderRadius: 6 },
|
|
608
|
-
!
|
|
699
|
+
!expanded && { ':active': { backgroundColor: pressWash(theme) } },
|
|
609
700
|
],
|
|
610
701
|
})
|
|
611
702
|
: h(Icon, {
|
package/src/frame/index.js
CHANGED
|
@@ -529,7 +529,9 @@ function PaneHostView({
|
|
|
529
529
|
}
|
|
530
530
|
let host;
|
|
531
531
|
try {
|
|
532
|
-
|
|
532
|
+
// `send` is for the windows the pane makes besides its own, which a
|
|
533
|
+
// backend whose pane process cannot show one shows here (Cocoa's)
|
|
534
|
+
host = app.createPaneHost(wnd, { send: (msg) => s.trySend?.(msg) });
|
|
533
535
|
} catch (err) {
|
|
534
536
|
embedErrorRef.current?.(err);
|
|
535
537
|
return undefined;
|
|
@@ -539,17 +541,44 @@ function PaneHostView({
|
|
|
539
541
|
// The pane's size is this box's laid-out size, told to the pane whenever
|
|
540
542
|
// it changes — the same absolutize hook a <foreign> child window uses
|
|
541
543
|
// (src/foreignnodes.js), because onViewport fires only for scrollers.
|
|
542
|
-
|
|
544
|
+
//
|
|
545
|
+
// And where the box is on the screen, in device pixels (`screen`): the
|
|
546
|
+
// pane has no window of its own to ask, and a popup it anchors to one of
|
|
547
|
+
// its nodes is placed against it (`windowOrigin`, src/anchor.js) — a
|
|
548
|
+
// dropdown in a pane opened from the screen's corner without it. It
|
|
549
|
+
// moves when the box does and when the window does, so it is sent on
|
|
550
|
+
// layout, on a scroll that shifts the box, and on every anchor change
|
|
551
|
+
// of the window, which a move of the window is.
|
|
552
|
+
let sent = null;
|
|
543
553
|
const syncSize = () => {
|
|
544
554
|
host.setRect(node.abs);
|
|
545
555
|
const sc = node.scale ?? 1;
|
|
546
556
|
const width = Math.max(1, Math.round(node.abs.width / sc));
|
|
547
557
|
const height = Math.max(1, Math.round(node.abs.height / sc));
|
|
548
|
-
|
|
558
|
+
const origin = node.root?.window?._screenOrigin;
|
|
559
|
+
const screen = origin
|
|
560
|
+
? {
|
|
561
|
+
x: Math.round(origin.x + node.abs.x),
|
|
562
|
+
y: Math.round(origin.y + node.abs.y),
|
|
563
|
+
}
|
|
564
|
+
: null;
|
|
565
|
+
if (
|
|
566
|
+
sent &&
|
|
567
|
+
sent.width === width &&
|
|
568
|
+
sent.height === height &&
|
|
569
|
+
sent.screen?.x === screen?.x &&
|
|
570
|
+
sent.screen?.y === screen?.y
|
|
571
|
+
) {
|
|
549
572
|
return;
|
|
550
573
|
}
|
|
551
|
-
|
|
552
|
-
s.trySend?.({
|
|
574
|
+
sent = { width, height, screen };
|
|
575
|
+
s.trySend?.({
|
|
576
|
+
type: 'pane-rect',
|
|
577
|
+
width,
|
|
578
|
+
height,
|
|
579
|
+
scale: sc,
|
|
580
|
+
...(screen && { screen }),
|
|
581
|
+
});
|
|
553
582
|
};
|
|
554
583
|
const origAbsolutize = node.absolutize.bind(node);
|
|
555
584
|
node.absolutize = (ox, oy) => {
|
|
@@ -560,13 +589,35 @@ function PaneHostView({
|
|
|
560
589
|
if (origShift) {
|
|
561
590
|
node._shiftAbs = (dx, dy) => {
|
|
562
591
|
origShift(dx, dy);
|
|
563
|
-
|
|
592
|
+
syncSize();
|
|
564
593
|
};
|
|
565
594
|
}
|
|
595
|
+
const offAnchor = node.root?.onAnchorChange?.(syncSize);
|
|
566
596
|
if (node.abs?.width) syncSize();
|
|
567
597
|
|
|
598
|
+
// The window's focus, which the pane has no window to hear: a menu it
|
|
599
|
+
// keeps open on the strength of the window having it closes when the
|
|
600
|
+
// user goes to another application (`useDismissOnWindowBlur`).
|
|
601
|
+
const offFocus = node.root?.onWindowFocusChange?.((focused) =>
|
|
602
|
+
s.trySend?.({
|
|
603
|
+
type: 'pane-event',
|
|
604
|
+
name: focused ? 'focus' : 'blur',
|
|
605
|
+
ev: { buttons: 0, time: Date.now() },
|
|
606
|
+
}),
|
|
607
|
+
);
|
|
608
|
+
|
|
568
609
|
const off = s.transport.onMessage((msg) => {
|
|
569
|
-
if (msg?.type === 'pane-
|
|
610
|
+
if (msg?.type === 'pane-window') {
|
|
611
|
+
host.paneWindow?.(msg);
|
|
612
|
+
} else if (msg?.type === 'pane-menu') {
|
|
613
|
+
host.popUpMenu?.(msg);
|
|
614
|
+
} else if (msg?.type === 'pane-menu-cancel') {
|
|
615
|
+
host.cancelMenu?.(msg.menu);
|
|
616
|
+
} else if (msg?.type === 'pane-present' && msg.window != null) {
|
|
617
|
+
host.presentWindow?.(msg);
|
|
618
|
+
} else if (msg?.type === 'pane-cursor' && msg.window != null) {
|
|
619
|
+
host.windowCursor?.(msg);
|
|
620
|
+
} else if (msg?.type === 'pane-present') {
|
|
570
621
|
host.setRect(node.abs);
|
|
571
622
|
host.present(msg.id);
|
|
572
623
|
} else if (msg?.type === 'pane-cursor') {
|
|
@@ -581,6 +632,8 @@ function PaneHostView({
|
|
|
581
632
|
});
|
|
582
633
|
return () => {
|
|
583
634
|
off();
|
|
635
|
+
offAnchor?.();
|
|
636
|
+
offFocus?.();
|
|
584
637
|
node.defaultCursor = undefined;
|
|
585
638
|
node.root?.events?.refreshCursor();
|
|
586
639
|
node.absolutize = origAbsolutize;
|
package/src/node.d.ts
CHANGED
|
@@ -284,6 +284,11 @@ export interface Sprite {
|
|
|
284
284
|
rect: Rect;
|
|
285
285
|
/** What it draws, untransformed, when that is more than `rect`. */
|
|
286
286
|
reach?: Rect;
|
|
287
|
+
/** Where it shows, when the element clips it: device pixels, window
|
|
288
|
+
* coordinates. Its layer is cut to the rect, and so it is to the clip of
|
|
289
|
+
* every ancestor with square corners (a rounded one has to hold all of
|
|
290
|
+
* it). */
|
|
291
|
+
clip?: Rect;
|
|
287
292
|
/** Draws the part where it is in the window, as `paint` would, but at
|
|
288
293
|
* opacity 1 and untransformed. */
|
|
289
294
|
paint(ctx: Context2D): void;
|
|
@@ -550,12 +550,22 @@ export class WindowNode extends Scrollable(Node) {
|
|
|
550
550
|
* relative to the *frame*, not the root. A popup anchored with them lands
|
|
551
551
|
* near the corner of the screen instead of under its trigger. The server
|
|
552
552
|
* will translate for us, and its answer is right whatever the WM did.
|
|
553
|
+
*
|
|
554
|
+
* A backend with no server to ask keeps the origin on its window object
|
|
555
|
+
* itself, current as of the event that moved it — Cocoa's
|
|
556
|
+
* `_nativeResized`, a frame pane's `setPaneSize`. There is no question to
|
|
557
|
+
* ask there, and what is left is what the answer would have done: tell
|
|
558
|
+
* whatever is anchored to this window that it moved. Without it a popup
|
|
559
|
+
* stayed where the window had been, and a frame pane told its new place
|
|
560
|
+
* on the screen dropped its next menu from the old one.
|
|
553
561
|
*/
|
|
554
562
|
_refreshScreenOrigin() {
|
|
555
563
|
const wnd = this.window;
|
|
564
|
+
if (!wnd) return;
|
|
556
565
|
const X = this.app?.X;
|
|
557
566
|
const root = X?.display?.screen?.[0]?.root;
|
|
558
|
-
if (
|
|
567
|
+
if (root == null || typeof X.TranslateCoordinates !== 'function') {
|
|
568
|
+
this._notifyAnchorChange();
|
|
559
569
|
return;
|
|
560
570
|
}
|
|
561
571
|
X.TranslateCoordinates(wnd.id, root, 0, 0, (err, res) => {
|
|
@@ -521,6 +521,16 @@ export interface SelectProps<T = unknown>
|
|
|
521
521
|
/** The chevron's style, over `textMuted` at the caption's cap height.
|
|
522
522
|
* `display: 'none'` leaves it out. */
|
|
523
523
|
chevronStyle?: StyleProp;
|
|
524
|
+
/**
|
|
525
|
+
* The menu's half of `native`: where the backend drops a control's menu
|
|
526
|
+
* as the platform's own (macOS, with `@windowkit/appkit` 0.20 or later),
|
|
527
|
+
* the list is that menu rather than a drawn one. Follows the trigger by
|
|
528
|
+
* default — the native bezel's select gets the native menu, a drawn one
|
|
529
|
+
* the drawn menu. `true` asks for the platform's menu under a drawn
|
|
530
|
+
* trigger too, set at the caption's size; `false` keeps the drawn menu.
|
|
531
|
+
* Ignored where the backend has no such menu.
|
|
532
|
+
*/
|
|
533
|
+
nativeMenu?: boolean;
|
|
524
534
|
}
|
|
525
535
|
export function Select<T = unknown>(props: SelectProps<T>): ReactNode;
|
|
526
536
|
|