react-x11 2.10.2 → 2.12.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.
Files changed (100) hide show
  1. package/README.md +278 -129
  2. package/package.json +10 -3
  3. package/src/Reconciler.js +15 -17
  4. package/src/a11y.js +2 -2
  5. package/src/anchor.js +7 -5
  6. package/src/bootstrap.js +14 -0
  7. package/src/clientmessage.js +1 -1
  8. package/src/cocoa/app.js +304 -49
  9. package/src/cocoa/bezels.js +175 -30
  10. package/src/cocoa/dnd.js +27 -13
  11. package/src/cocoa/fonts.js +3 -3
  12. package/src/cocoa/glarea.js +20 -3
  13. package/src/cocoa/main.d.ts +8 -0
  14. package/src/cocoa/main.js +43 -0
  15. package/src/cocoa/panehost.js +15 -5
  16. package/src/cocoa/presenter.js +13 -9
  17. package/src/cocoa/promotion.js +4 -7
  18. package/src/cocoa/relaunch.js +207 -0
  19. package/src/cocoa/screencolor.js +62 -0
  20. package/src/cocoa/threaded.js +246 -0
  21. package/src/cocoa/window.js +256 -42
  22. package/src/components/Select.js +2 -2
  23. package/src/components/anchor.js +3 -3
  24. package/src/components/native.js +12 -7
  25. package/src/components/theme.js +2 -2
  26. package/src/debug.js +1 -1
  27. package/src/decorations.js +1 -1
  28. package/src/editmenu.js +2 -2
  29. package/src/errors.js +46 -0
  30. package/src/events.js +6 -6
  31. package/src/foreignnodes.js +3 -2
  32. package/src/frames.js +2 -2
  33. package/src/glnodes.js +1 -1
  34. package/src/grid.js +1653 -0
  35. package/src/host.d.ts +230 -0
  36. package/src/host.js +11 -3
  37. package/src/imagesource.js +1 -1
  38. package/src/index.d.ts +21 -4
  39. package/src/index.js +9 -1
  40. package/src/layouts.js +721 -0
  41. package/src/node.d.ts +4 -2
  42. package/src/node.js +19 -21
  43. package/src/nodes/animation.js +644 -0
  44. package/src/nodes/box.js +21 -0
  45. package/src/nodes/boxpaint.js +473 -0
  46. package/src/nodes/canvas.js +269 -0
  47. package/src/nodes/cascade.js +600 -0
  48. package/src/nodes/damage.js +183 -0
  49. package/src/nodes/edithistory.js +124 -0
  50. package/src/nodes/editmenupopup.js +260 -0
  51. package/src/nodes/hittest.js +185 -0
  52. package/src/nodes/image.js +266 -0
  53. package/src/nodes/install.js +75 -0
  54. package/src/nodes/invalidate.js +465 -0
  55. package/src/nodes/kinds.js +31 -0
  56. package/src/nodes/layout.js +439 -0
  57. package/src/nodes/layouthost.js +949 -0
  58. package/src/nodes/node.js +868 -0
  59. package/src/nodes/paint.js +466 -0
  60. package/src/nodes/position.js +366 -0
  61. package/src/nodes/preedit.js +127 -0
  62. package/src/nodes/queries.js +330 -0
  63. package/src/nodes/rects.js +102 -0
  64. package/src/nodes/scrollable.js +891 -0
  65. package/src/nodes/scrollbars.js +138 -0
  66. package/src/nodes/scrollblit.js +1034 -0
  67. package/src/nodes/selectable.js +142 -0
  68. package/src/nodes/styling.js +225 -0
  69. package/src/nodes/text.js +649 -0
  70. package/src/nodes/textarea.js +391 -0
  71. package/src/nodes/textinput.js +1146 -0
  72. package/src/nodes/util.js +17 -0
  73. package/src/nodes/window/anchoring.js +161 -0
  74. package/src/nodes/window/capabilities.js +190 -0
  75. package/src/nodes/window/debugpaint.js +83 -0
  76. package/src/nodes/window/droptarget.js +145 -0
  77. package/src/nodes/window/floors.js +577 -0
  78. package/src/nodes/window/flush.js +334 -0
  79. package/src/nodes/window/hints.js +482 -0
  80. package/src/nodes/window/listeners.js +222 -0
  81. package/src/nodes/window/popup.js +71 -0
  82. package/src/nodes/window/size.js +591 -0
  83. package/src/nodes/window/window.js +945 -0
  84. package/src/palette.js +1 -1
  85. package/src/registry.js +7 -3
  86. package/src/screencolor.js +212 -38
  87. package/src/screencolorhooks.js +6 -2
  88. package/src/styles.js +137 -15
  89. package/src/svgnodes.js +2 -1
  90. package/src/testing/harness.js +2 -2
  91. package/src/textselection.js +5 -3
  92. package/src/trace-registry.js +1 -1
  93. package/src/types/components.d.ts +38 -6
  94. package/src/types/elements.d.ts +11 -1
  95. package/src/types/nodes.d.ts +33 -5
  96. package/src/types/screencolor.d.ts +20 -14
  97. package/src/types/style.d.ts +94 -3
  98. package/src/windowstate.js +1 -1
  99. package/src/yoga.js +1 -1
  100. package/src/nodes.js +0 -13120
@@ -0,0 +1,17 @@
1
+ // Small shared pieces with no more specific home under src/nodes/.
2
+
3
+ export const DEV = process.env.NODE_ENV !== 'production';
4
+
5
+ /** Equality for props that may be a scalar, an array or a plain object
6
+ * (window hints are all three shapes), so an unchanged inline object
7
+ * literal does not re-send the property every render. */
8
+ export function shallowEqual(a, b) {
9
+ if (a === b) return true;
10
+ if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
11
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
12
+ const ka = Object.keys(a);
13
+ const kb = Object.keys(b);
14
+ return ka.length === kb.length && ka.every((k) => a[k] === b[k]);
15
+ }
16
+
17
+ export const NO_CHILDREN = Object.freeze([]);
@@ -0,0 +1,161 @@
1
+ // A window anchored to a rect in another (#255, #280): where it goes, and
2
+ // following the anchor as it moves.
3
+
4
+ import { anchorOffscreen, anchorRect } from '../../anchor.js';
5
+
6
+ /** Anchoring, installed onto `WindowNode.prototype` by window.js. */
7
+ export class WindowAnchoring {
8
+ // --- anchoring ----------------------------------------------------------
9
+ //
10
+ // A `<popup anchor={{to: ref, …}}>` works out its own position, because it
11
+ // is the only thing that can: with `width="auto"` the size is settled
12
+ // inside `realize()`, between `_measure()` and `CreateWindow`, which is
13
+ // after the last moment React could have computed a rect for it — and the
14
+ // placement *needs* the size, since which side it flips to and how far it
15
+ // is pulled back from an edge are both functions of how big it is.
16
+ //
17
+ // So the same order the natural size already established: measure, place,
18
+ // create. The popup is born the right size **and** in the right place,
19
+ // rather than mapped somewhere provisional and corrected a frame later.
20
+ // A widget that knows its own size has no such problem and stays on
21
+ // `useAnchor` / `useAnchorTracking`; both call the same functions
22
+ // (src/anchor.js), so the two agree by construction.
23
+
24
+ /** A `to`/`alignTo` in an `anchor` prop: a ref, or the node itself. */
25
+ _anchorTarget(target) {
26
+ if (target == null || typeof target !== 'object') return null;
27
+ const node = target.abs ? target : target.current;
28
+ return node?.abs ? node : null;
29
+ }
30
+
31
+ /** Where this window goes at `size`, or null when there is nothing to
32
+ * anchor to yet — a ref whose node has not been laid out. */
33
+ _anchorPlacement(size) {
34
+ const anchor = this.props.anchor;
35
+ const node = this._anchorTarget(anchor?.to);
36
+ if (!node) return null;
37
+ // `anchorRect` is public API and speaks logical pixels on both sides;
38
+ // this caller's `size` came from `_measure` (device) and its result is
39
+ // headed for CreateWindow (device), so both convert here.
40
+ const s = this.scale;
41
+ const rect = anchorRect(node, {
42
+ ...anchor,
43
+ alignTo: this._anchorTarget(anchor.alignTo) ?? undefined,
44
+ width: size.width / s,
45
+ height: size.height / s,
46
+ });
47
+ if (!rect || s === 1) return rect;
48
+ return {
49
+ ...rect,
50
+ x: Math.round(rect.x * s),
51
+ y: Math.round(rect.y * s),
52
+ width: Math.round(rect.width * s),
53
+ height: Math.round(rect.height * s),
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Keep an anchored window over the thing it points at: the trigger's own
59
+ * layout moving, an ancestor of it scrolling, the owner window being
60
+ * dragged, or this window's content changing size under it.
61
+ *
62
+ * The first three arrive through the *owner* window's `onAnchorChange`
63
+ * (`_watchAnchor`) — the same signal `useAnchorTracking` reads, since it
64
+ * is the same set of things that can move a trigger. The fourth is
65
+ * `_refit`, which knows the new size before the server does.
66
+ *
67
+ * **Out of view is not a position.** A popup is a real X window, not a
68
+ * web element its ancestors clip, so a caret that has scrolled out of the
69
+ * editor leaves a completion list floating over a document it no longer
70
+ * points into. There is no placement that fixes that, so the window is
71
+ * unmapped for as long as the anchor is gone and mapped again where it
72
+ * belongs when it comes back — the one answer the renderer can give on
73
+ * its own, since whether the popup should *close* is React state and
74
+ * therefore the application's. (An app that would rather close it keeps
75
+ * `useAnchorTracking`'s `onOutOfView`, which is exactly that seam.)
76
+ */
77
+ _followAnchor(size = this._requestedSize) {
78
+ if (this.destroyed || !this.window || !this.props.anchor) return;
79
+ const node = this._anchorTarget(this.props.anchor.to);
80
+ // A ref that has not attached yet counts as gone, and for the same
81
+ // reason: there is nowhere for the popup to be. Refs attach in the
82
+ // commit phase a popup realizes in, so one written *above* its own
83
+ // trigger in the JSX gets a frame of this — and waiting it out is
84
+ // better than a frame in the corner of the screen.
85
+ const lost = !node || anchorOffscreen(node, this.props.anchor.at);
86
+ if (lost !== Boolean(this._anchorLost)) {
87
+ this._anchorLost = lost;
88
+ // The grab goes with it and comes back with it. X releases a pointer
89
+ // grab whose window stops being viewable, so a menu that hid and
90
+ // reappeared would be one no press outside could dismiss — the
91
+ // `onDismiss` that reads as "click anywhere to close" would simply
92
+ // stop happening. The re-take is `PopupNode._mapNow`'s, which is what
93
+ // keeps it beside the map on every route back to the screen.
94
+ if (lost) {
95
+ if (this.props.grab) this.window.ungrabPointer?.();
96
+ this.window.unmap?.();
97
+ } else {
98
+ this._mapNow();
99
+ }
100
+ }
101
+ if (lost || !size) return;
102
+ const rect = this._anchorPlacement(size);
103
+ if (!rect) return;
104
+ if (this._placedAt?.x === rect.x && this._placedAt?.y === rect.y) return;
105
+ this._placedAt = { x: rect.x, y: rect.y };
106
+ if (typeof this.window.setState === 'function') {
107
+ this.window.setState({ x: rect.x, y: rect.y });
108
+ } else {
109
+ this.window.move?.(rect.x, rect.y);
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Subscribe to whatever can move the anchor. On the **owner's** window,
115
+ * not on this one: what moves is the trigger, and this window's own
116
+ * layout passes say nothing about where it sits on screen.
117
+ */
118
+ _watchAnchor() {
119
+ this._unwatchAnchor();
120
+ if (!this.props.anchor) return;
121
+ // The anchor's own window where the ref has attached, and the window
122
+ // this popup was *written into* otherwise — which is the same one in
123
+ // every case that matters, and is what makes an unattached ref a
124
+ // one-frame wait rather than a popup nothing ever notifies again. The
125
+ // notification is only ever a prompt to re-measure, so subscribing to a
126
+ // window the anchor turns out not to be in costs a no-op.
127
+ const root =
128
+ this._anchorTarget(this.props.anchor.to)?.root ?? this.parent?.root;
129
+ if (!root?.onAnchorChange || root === this) return;
130
+ this._anchorWatch = root.onAnchorChange(() => this._followAnchor());
131
+ }
132
+
133
+ _unwatchAnchor() {
134
+ this._anchorWatch?.();
135
+ this._anchorWatch = null;
136
+ }
137
+
138
+ /**
139
+ * Subscribe to "something this window's popups might be anchored to just
140
+ * moved" — a real layout pass (a trigger's own position changing: text
141
+ * wrapping, a sibling growing, an ancestor viewport scrolling — scroll
142
+ * offset is applied during absolutize, so it is a layout change too) or a
143
+ * fresh `_screenOrigin` (the window manager or a script moving this window).
144
+ * Returns an unsubscribe function.
145
+ *
146
+ * Event-driven off the same signals `flush()` and `_refreshScreenOrigin()`
147
+ * already track internally, rather than a polling loop: costs nothing
148
+ * between real changes, and `useAnchor`'s tracking hook (`anchor.js`) is
149
+ * what turns this into a popup that follows its trigger instead of hanging
150
+ * over stale ground once opened.
151
+ */
152
+ onAnchorChange(cb) {
153
+ (this._anchorListeners ??= new Set()).add(cb);
154
+ return () => this._anchorListeners?.delete(cb);
155
+ }
156
+
157
+ _notifyAnchorChange() {
158
+ if (!this._anchorListeners?.size) return;
159
+ for (const cb of this._anchorListeners) cb();
160
+ }
161
+ }
@@ -0,0 +1,190 @@
1
+ // What a window can paint: compositing and ARGB transparency, the window
2
+ // background, and the capabilities `@supports` blocks read.
3
+
4
+ import { cssColorStraight } from 'ntk';
5
+ import {
6
+ argbVisual,
7
+ compositingActive,
8
+ transparencyDisabled,
9
+ watchCompositing,
10
+ } from '../../compositing.js';
11
+ import { DEV } from '../util.js';
12
+
13
+ /**
14
+ * A CSS colour as an X pixel value.
15
+ *
16
+ * The 24-bit TrueColor layout, which is what `redMask`/`greenMask`/`blueMask`
17
+ * say on every server a client meets today and what ntk's own ARGB visual
18
+ * uses. Alpha is dropped: this is the *window background attribute*, a single
19
+ * opaque pixel the server repeats, not something composited.
20
+ */
21
+ export function pixelFor(color) {
22
+ const rgba = cssColorStraight(color);
23
+ if (!rgba) return null;
24
+ const [r, g, b] = rgba;
25
+ const byte = (c) => Math.max(0, Math.min(255, Math.round(c * 255)));
26
+ return ((byte(r) << 16) | (byte(g) << 8) | byte(b)) >>> 0;
27
+ }
28
+
29
+ // Connections already told they have no 32-bit visual (`_argbAttributes`).
30
+ const warnedNoArgb = new WeakSet();
31
+
32
+ /**
33
+ * `boxShadow` on a `<window>`/`<popup>`, warned about once.
34
+ *
35
+ * A shadow falls *outside* the box, and outside a toplevel there is nothing
36
+ * of ours to paint on — the pixels belong to the desktop. Doing it properly
37
+ * means the window asking for a translucent margin it does not otherwise
38
+ * want (an ARGB visual, a bigger X window, and hit testing that knows the
39
+ * difference), which is a feature of its own rather than a line in the
40
+ * painter. Said out loud because the alternative is a style that reads as
41
+ * ignored for no reason.
42
+ */
43
+ let warnedWindowShadow = false;
44
+ export function devWarnWindowShadow(kind) {
45
+ if (warnedWindowShadow) return;
46
+ warnedWindowShadow = true;
47
+ console.warn(
48
+ `react-x11: boxShadow on <${kind}> is ignored — a shadow is painted ` +
49
+ 'outside the box, and a toplevel window owns no pixels there. Put the ' +
50
+ 'shadow on a <box> inside it, or draw the window with a translucent ' +
51
+ 'margin of its own (docs/styling.md).',
52
+ );
53
+ }
54
+
55
+ /** What the window can paint, installed onto `WindowNode.prototype` by window.js. */
56
+ export class WindowCapabilities {
57
+ /**
58
+ * What this window can actually do, for `@supports` blocks to read and for
59
+ * the paint path to obey. Read-only to callers; recomputed by
60
+ * `_refreshCapabilities`.
61
+ */
62
+ get capabilities() {
63
+ return this._capabilities;
64
+ }
65
+
66
+ /**
67
+ * Will transparency actually be *seen*? Both halves have to hold: the
68
+ * window needs an alpha channel to write, and something has to be
69
+ * compositing it. Miss either and a cleared corner is a black corner, so
70
+ * the paint path fills opaque instead.
71
+ *
72
+ * This is deliberately not the same question as "was `transparent` asked
73
+ * for". A visual is fixed at CreateWindow and cannot follow a compositor
74
+ * that starts or stops mid-session; what it paints can, and does.
75
+ */
76
+ get transparencyEffective() {
77
+ return this._capabilities.transparency;
78
+ }
79
+
80
+ /**
81
+ * Recompute, and if the answer moved, re-resolve every `@supports` block
82
+ * under this window and repaint. Returns whether anything changed.
83
+ */
84
+ _refreshCapabilities() {
85
+ const transparency = this._transparent && compositingActive(this.app);
86
+ if (transparency === this._capabilities.transparency) return false;
87
+ // a new object rather than a mutation: `resolveQueries` may have handed
88
+ // this map to a memoized style, and identity is how that stays honest
89
+ this._capabilities = { ...this._capabilities, transparency };
90
+ // The window's own style first, and separately: it resolves its style
91
+ // in the Node constructor, before `root` exists to register against, so
92
+ // it is not in its own registry on the pass that matters — the one
93
+ // realize() triggers once the visual is known.
94
+ if (this._supportsQueried) this._sizeQueriesChanged();
95
+ for (const node of [...this._supportsQueryNodes]) {
96
+ if (node.destroyed) this._supportsQueryNodes.delete(node);
97
+ else if (node !== this) node._sizeQueriesChanged();
98
+ }
99
+ // The window's own background is not a styled node and has no query
100
+ // block to re-resolve — it reads `transparencyEffective` directly, so
101
+ // it just needs the repaint.
102
+ this.invalidate(true, null, 'capabilities');
103
+ return true;
104
+ }
105
+
106
+ /**
107
+ * Follow the compositor for the life of the window. A menu that was opaque
108
+ * because nothing was compositing becomes a rounded translucent one the
109
+ * moment something is, with no remount — which is the whole reason the
110
+ * ARGB visual is taken even when no compositor is running yet.
111
+ */
112
+ _watchCapabilities() {
113
+ this._refreshCapabilities();
114
+ this._unwatchCompositing ??= watchCompositing(this.app, () => {
115
+ if (!this.destroyed) this._refreshCapabilities();
116
+ });
117
+ }
118
+
119
+ /**
120
+ * Creation attributes for a per-pixel transparent window: a 32-bit
121
+ * TrueColor visual, and a background of transparent black rather than the
122
+ * server's white, so nothing flashes before the first paint. ntk gives the
123
+ * window its own colormap and border pixel to go with the visual —
124
+ * inheriting either from a parent of a different depth is a BadMatch.
125
+ *
126
+ * Empty when the display has no such visual (XQuartz has none) or ntk is
127
+ * too old to find one, and then `_transparent` stays false and the window
128
+ * paints its background opaque, exactly as it did before. A transparent
129
+ * window that cannot be transparent is a square opaque one, not a broken
130
+ * one — and the alternative, black corners, is worse than square.
131
+ */
132
+ /** What this window paints as its background — its own, or the palette's. */
133
+ _windowBackground() {
134
+ return this.style.backgroundColor || this.theme.background;
135
+ }
136
+
137
+ /**
138
+ * Keep the server's idea of the background in step with ours. Called when
139
+ * the palette moves under an unstyled window and when the style names a new
140
+ * colour; a `transparent` window keeps its 0, which means transparent.
141
+ */
142
+ _syncWindowBackground() {
143
+ if (this._transparent || !this.window || this.destroyed) return;
144
+ const pixel = pixelFor(this._windowBackground());
145
+ if (pixel === null || pixel === this._backgroundPixel) return;
146
+ if (this.app?.X?._closing) return;
147
+ this._backgroundPixel = pixel;
148
+ // One call for both halves, because a double-buffered window has two
149
+ // backgrounds: the X window attribute the server paints into exposed
150
+ // area, and the colour ntk's backing store clears the part a resize
151
+ // grows into. They used to be free to disagree — the backing store
152
+ // cleared to the screen's white whatever the window said — which is why
153
+ // enlarging a dark window flashed a white strip that survived until
154
+ // something damaged it (ntk#209, 6.6.1).
155
+ this.window.setBackgroundPixel?.(pixel);
156
+ }
157
+
158
+ _argbAttributes() {
159
+ const argb = argbVisual(this.app);
160
+ if (!argb) {
161
+ // Once per connection. The answer is a property of the display (or of
162
+ // an environment switch) and cannot change while it is open, and since
163
+ // the widgets ask for a transparent popup every time a menu or a
164
+ // tooltip opens, warning per window would turn one piece of news into
165
+ // a running commentary.
166
+ if (DEV && this.app && !warnedNoArgb.has(this.app)) {
167
+ warnedNoArgb.add(this.app);
168
+ const what = this.isPopup ? 'popup' : 'window';
169
+ if (transparencyDisabled()) {
170
+ console.warn(
171
+ 'react-x11: REACT_X11_NO_TRANSPARENCY=1 — <%s transparent> ' +
172
+ 'ignored, this run is opaque',
173
+ what,
174
+ );
175
+ } else {
176
+ console.warn(
177
+ 'react-x11: <%s transparent> — no 32-bit TrueColor visual on ' +
178
+ 'this display, falling back to an opaque window',
179
+ what,
180
+ );
181
+ }
182
+ }
183
+ return null;
184
+ }
185
+ // What the paint path keys off: the window really does have an alpha
186
+ // channel, so clearing it means transparent rather than white.
187
+ this._transparent = true;
188
+ return { ...argb, backgroundPixel: 0 };
189
+ }
190
+ }
@@ -0,0 +1,83 @@
1
+ // Debug overlays: REACT_X11_DEBUG_PAINT's damage flashes, and DevTools'
2
+ // highlight and trace-updates outlines.
3
+
4
+ // REACT_X11_DEBUG_PAINT: each frame strokes its damage rects in the next of
5
+ // these, so a region repainting every frame strobes visibly.
6
+ export const FLASH_COLORS = [
7
+ '#e6194b',
8
+ '#3cb44b',
9
+ '#ffe119',
10
+ '#4363d8',
11
+ '#f58231',
12
+ '#911eb4',
13
+ ];
14
+
15
+ // REACT_X11_DEBUG_PAINT, read once: a process.env read is a real
16
+ // environment lookup, and this switch sits on invalidate() and the paint
17
+ // loop — the diagnostics must cost nothing when they are off. Indirected
18
+ // like the animation clock so tests can flip it without a subprocess.
19
+ export let debugPaint = process.env.REACT_X11_DEBUG_PAINT || '';
20
+ export function setDebugPaint(mode) {
21
+ debugPaint = mode || '';
22
+ }
23
+
24
+ /** Debug overlays, installed onto `WindowNode.prototype` by window.js. */
25
+ export class WindowDebugPaint {
26
+ /** DevTools hover highlight: tint a node's rect on the next paint. */
27
+ setHighlight(node) {
28
+ if (this._highlight === node) return;
29
+ const prev = this._highlight;
30
+ this._highlight = node;
31
+ // The tint leaves one rect and lands on another, and both are already
32
+ // known, so the claim is their union rather than the window. A side
33
+ // with no laid-out rect tints (or tinted) the whole window — the same
34
+ // fallback _paintRegion paints — so only that case stays unbounded.
35
+ const rects = [];
36
+ for (const n of [prev, node]) {
37
+ if (!n) continue;
38
+ if (!n.abs?.width) {
39
+ this.invalidate(false, null, 'highlight');
40
+ return;
41
+ }
42
+ rects.push(n.abs);
43
+ }
44
+ for (const rect of rects) this.invalidate(false, rect, 'highlight');
45
+ }
46
+
47
+ /**
48
+ * DevTools' "highlight updates when components render": outline the rects
49
+ * that just re-rendered, in the colour the backend assigned each one (it
50
+ * ramps with the update count and fades them out on its own clock, so
51
+ * this is a dumb overlay — `rects` is the whole state, `null` clears it).
52
+ */
53
+ setTraceUpdates(rects) {
54
+ const previous = this._traceUpdates;
55
+ const next = rects?.length ? rects : null;
56
+ if (!previous && !next) return;
57
+ this._traceUpdates = next;
58
+ // The stroke sits inside the rect, but a rect whose node has since
59
+ // moved or gone claims where it *was*; both lists are claimed for the
60
+ // same reason setHighlight claims both of its rects.
61
+ for (const r of [...(previous ?? []), ...(next ?? [])]) {
62
+ this.invalidate(false, r, 'trace-updates');
63
+ }
64
+ }
65
+
66
+ /** REACT_X11_DEBUG_LAYOUT=1: outline every drawn node, color by depth. */
67
+ _paintDebugOverlay(ctx, node, depth) {
68
+ const colors = ['#e74c3c', '#27ae60', '#2980b9', '#8e44ad', '#f39c12'];
69
+ for (const child of node.paintOrder()) {
70
+ ctx.strokeStyle = colors[depth % colors.length];
71
+ ctx.lineWidth = 1;
72
+ ctx.beginPath();
73
+ ctx.rect(
74
+ child.abs.x + 0.5,
75
+ child.abs.y + 0.5,
76
+ child.abs.width - 1,
77
+ child.abs.height - 1,
78
+ );
79
+ ctx.stroke();
80
+ this._paintDebugOverlay(ctx, child, depth + 1);
81
+ }
82
+ }
83
+ }
@@ -0,0 +1,145 @@
1
+ // The window's half of XDND (docs/architecture/drag-and-drop.md): the drop
2
+ // targets registered under it and the types they accept.
3
+
4
+ import {
5
+ DropSession,
6
+ dndAtoms,
7
+ registerTopLevel,
8
+ XDND_VERSION,
9
+ } from '../../dnd.js';
10
+ import { TYPE_GROUPS } from '../../transfer.js';
11
+
12
+ /** The window's half of XDND, installed onto `WindowNode.prototype` by window.js. */
13
+ export class WindowDropTarget {
14
+ /**
15
+ * XDND drop-target wiring (src/dnd.js): write `XdndAware = 5`, start the
16
+ * atom interning, and route incoming ClientMessages to the session.
17
+ * Unconditional — the property is 4 bytes on a window that exists anyway,
18
+ * and advertising lazily would race sources that cache the window list
19
+ * at drag start. A window with no registered drop targets answers "not
20
+ * accepting" once per entry instead (DropSession).
21
+ *
22
+ * The one exception is a `<popup dragPreview>`. It follows the pointer,
23
+ * so for the whole gesture it is the frontmost window under it, and it
24
+ * must never be what the drag is over. Where react-x11 picks the target
25
+ * itself (src/dnd.js `topLevelAt`) it is skipped by name. Where the OS
26
+ * picks, registering nothing is not enough: AppKit finds the window
27
+ * under the pointer first and does not look past one with no dragged
28
+ * types — the drag then has no destination at all — so the cocoa window
29
+ * is made transparent to the pointer instead (src/cocoa/window.js,
30
+ * `ignoresMouseEvents`, #488). A preview still gets none of this: no
31
+ * session, no registry entry, no property, nothing to refuse with.
32
+ */
33
+ _initDnd() {
34
+ if (this.props.dragPreview) return;
35
+ const wnd = this.window;
36
+ const X = this.app?.X;
37
+ // A backend with drop machinery of its own (the cocoa backend's
38
+ // NSDraggingDestination, src/cocoa/dnd.js): the same DropSession, driven
39
+ // through its local entry points by the window's transport instead of
40
+ // by XDND ClientMessages. No property to write, nothing to intern.
41
+ if (typeof wnd?.attachDropTransport === 'function') {
42
+ this._dnd = new DropSession(this);
43
+ registerTopLevel(this);
44
+ wnd.attachDropTransport(this._dnd, this);
45
+ return;
46
+ }
47
+ if (
48
+ !X ||
49
+ typeof X.InternAtom !== 'function' ||
50
+ typeof wnd.on !== 'function' ||
51
+ typeof wnd.setProperty !== 'function'
52
+ ) {
53
+ return; // mock app, or an ntk too old to write raw properties
54
+ }
55
+ this._dnd = new DropSession(this);
56
+ registerTopLevel(this);
57
+ void dndAtoms(X).catch(() => {});
58
+ wnd
59
+ .setProperty('XdndAware', [XDND_VERSION], { type: 'ATOM' })
60
+ .catch(() => {});
61
+ wnd.on('message', (ev) => {
62
+ // XDND is a *default action* on a ClientMessage, so it follows the same
63
+ // rule every other one does: it runs after the application's handler
64
+ // and is skipped when that handler called `preventDefault()`. That is
65
+ // the seam for a window answering the drag protocol itself.
66
+ //
67
+ // `_attachWindowListeners` subscribed to this stream first, so normally
68
+ // the flag is already decided by the time this runs. `pending()` is the
69
+ // exception it cannot cover: a message whose type had to be named with
70
+ // a round trip is dispatched a few turns later, and answering the drag
71
+ // before the application has been asked would make `preventDefault()`
72
+ // depend on whether an atom happened to be cached.
73
+ const said = this._clientMessages?.pending();
74
+ const route = () => {
75
+ if (ev.defaultPrevented) return;
76
+ this._dnd.handleMessage(ev);
77
+ // a drag *out* of this window gets its XdndStatus/XdndFinished back
78
+ // on the same channel
79
+ this._dragSession?.handleMessage(ev);
80
+ };
81
+ if (said) said.then(route);
82
+ else route();
83
+ });
84
+ }
85
+
86
+ /** Nodes with drop props register with their root; the count gates the
87
+ * whole-window "not accepting" fast path. Child <window>s roll up into
88
+ * their top-level's count, since that is where the messages arrive. */
89
+ _registerDropTarget(node) {
90
+ (this._dropTargets ??= new Set()).add(node);
91
+ this._dndTopLevel()?.window?.dropTargetsChanged?.();
92
+ }
93
+
94
+ _forgetDropTarget(node) {
95
+ this._dropTargets?.delete(node);
96
+ this._dndOwner()?.forget(node);
97
+ this._dndTopLevel()?.window?.dropTargetsChanged?.();
98
+ }
99
+
100
+ /** The top-level whose drop session — and transport — this window's
101
+ * targets roll up into. */
102
+ _dndTopLevel() {
103
+ let node = this;
104
+ while (node && !node._dnd) node = node.parent?.root;
105
+ return node ?? null;
106
+ }
107
+
108
+ /** The concrete type names every `dropAccept` under this top-level asks
109
+ * for — what a backend that registers its accepted types up front (the
110
+ * cocoa backend) adds to its base set. Groups and predicates name none. */
111
+ _dndConcreteTypes() {
112
+ const out = new Set();
113
+ const walk = (wn) => {
114
+ for (const node of wn._dropTargets ?? []) {
115
+ const accept = node.props.dropAccept;
116
+ for (const entry of Array.isArray(accept) ? accept : []) {
117
+ if (typeof entry === 'string' && !(entry in TYPE_GROUPS)) {
118
+ out.add(entry);
119
+ }
120
+ }
121
+ }
122
+ for (const child of wn.children) {
123
+ if (child.isWindow && !child.isPopup) walk(child);
124
+ }
125
+ };
126
+ walk(this);
127
+ return [...out];
128
+ }
129
+
130
+ _dndTargetCount() {
131
+ let count = this._dropTargets?.size ?? 0;
132
+ for (const child of this.children) {
133
+ if (child.isWindow && !child.isPopup) count += child._dndTargetCount();
134
+ }
135
+ return count;
136
+ }
137
+
138
+ /** The session that owns drags over this window: its own for a
139
+ * top-level, the enclosing top-level's for a nested <window>. */
140
+ _dndOwner() {
141
+ let node = this;
142
+ while (node && !node._dnd) node = node.parent?.root;
143
+ return node?._dnd ?? null;
144
+ }
145
+ }