react-x11 2.7.0 → 2.8.1

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.
@@ -9,6 +9,7 @@
9
9
  import { CocoaContext2D } from './context2d.js';
10
10
  import { CocoaDropTransport, dragSpec } from './dnd.js';
11
11
  import { CocoaLayerPresenter } from './presenter.js';
12
+ import { CocoaPromotion } from './promotion.js';
12
13
 
13
14
  let nextWindowId = 1;
14
15
 
@@ -110,6 +111,22 @@ export class CocoaWindow {
110
111
  this._presenter.animate(node, prop, entry);
111
112
  this.cancelNodeAnimation = (node, prop) =>
112
113
  this._presenter.cancel(node, prop);
114
+ } else if (app._promote) {
115
+ // Layer promotion (src/cocoa/promotion.js): the surface presenter
116
+ // keeps the frame, and the nodes that animate get a layer of their
117
+ // own above it. The same two animation hooks as layers mode; the
118
+ // invalidate channel, for what the promoted rasters repaint; and the
119
+ // frame's word in before the paint, where a node is moved onto or off
120
+ // its layer and the bitmap under it claimed in the same frame.
121
+ this._promotion = new CocoaPromotion(this);
122
+ this.animateNode = (node, prop, entry) =>
123
+ this._promotion.animate(node, prop, entry);
124
+ this.cancelNodeAnimation = (node, prop) =>
125
+ this._promotion.cancel(node, prop);
126
+ this.noteInvalidate = (damage, layoutChanged) =>
127
+ this._promotion.noteInvalidate(damage, layoutChanged);
128
+ this.prepareFrame = (root, layoutRan) =>
129
+ this._promotion.frame(root, layoutRan);
113
130
  }
114
131
  app._registerWindow(this);
115
132
  }
@@ -224,6 +241,7 @@ export class CocoaWindow {
224
241
  this.app.cancelAttention(this._attentionRequest);
225
242
  this._attentionRequest = null;
226
243
  }
244
+ this._promotion?.destroy();
227
245
  this._native.destroyWindow2(this._h);
228
246
  this._releaseBacking();
229
247
  }
@@ -312,13 +330,23 @@ export class CocoaWindow {
312
330
  * The source side: hand a DragSession's gesture to an NSDraggingSession
313
331
  * (see src/cocoa/dnd.js for what the spec carries). Returns at once; the
314
332
  * session reports back as `drag-session-*` events.
333
+ *
334
+ * And the pump stops here. AppKit tracks the gesture on this thread, so
335
+ * `pump2` does not return until the drop and no timer of ours runs in
336
+ * between — the frame that shows the drag has begun (a `<popup
337
+ * dragPreview>` mounted by `onDragStart`, a source dimmed by
338
+ * `:dragging`) has to go out on the way past. Motion is otherwise paced
339
+ * on the frame clock and not flushed per event (`_routeMotion`); this is
340
+ * the one motion whose answer has no next tick to wait for.
315
341
  */
316
342
  beginDrag(session) {
317
343
  if (this.destroyed) return null;
318
- return this._native.beginDrag(
344
+ const began = this._native.beginDrag(
319
345
  this._h,
320
346
  dragSpec(session, this._native, this.scale),
321
347
  );
348
+ this.app._afterInput();
349
+ return began;
322
350
  }
323
351
 
324
352
  setTransientFor() {
package/src/dnd.js CHANGED
@@ -258,6 +258,13 @@ export class DropSession {
258
258
  // FIFO gate: messages queue behind atom interning and XdndEnter's
259
259
  // async type-list resolution, so a Position never overtakes its Enter.
260
260
  this._chain = Promise.resolve();
261
+ // The lane an enter/over dispatch schedules its renders in. Continuous
262
+ // by default, because a drag hovering is a stream like the pointer's
263
+ // own and the frame clock paces it. A transport whose backend *stops*
264
+ // that clock for the duration of the drag raises it — the cocoa one
265
+ // does (src/cocoa/dnd.js), because inside AppKit's tracking loop a
266
+ // render scheduled for the next frame lands after the drop.
267
+ this.hoverPriority = ContinuousEventPriority;
261
268
  this._reset();
262
269
  }
263
270
 
@@ -470,7 +477,7 @@ export class DropSession {
470
477
  action: this.requestedAction,
471
478
  freeze: false,
472
479
  };
473
- runWithPriority(ContinuousEventPriority, () => {
480
+ runWithPriority(this.hoverPriority, () => {
474
481
  this._updateDragPath(path, native);
475
482
  // onDragOver may override the declarative answer, synchronously —
476
483
  // same latency budget as any event handler, no render awaited
@@ -1258,20 +1265,6 @@ export class DragSession {
1258
1265
  Array.isArray(actions) && actions.length > 0 ? actions : ['copy'];
1259
1266
  this.currentAction = this.actions[0];
1260
1267
  this._resolved = new Map();
1261
- const ev = this.node.events.dispatch('DragStart', source, native, {
1262
- types: this.types,
1263
- action: this.currentAction,
1264
- source: 'internal',
1265
- screenX: (native.rootx ?? native.x) / this.node.events.scale,
1266
- screenY: (native.rooty ?? native.y) / this.node.events.scale,
1267
- });
1268
- if (ev.defaultPrevented) {
1269
- this._reset();
1270
- return false;
1271
- }
1272
- this.phase = 'dragging';
1273
- source.setStyleState(':dragging', true);
1274
- this._setCursor('grab');
1275
1268
  // A backend with a drag session of its own (the cocoa backend's
1276
1269
  // NSDraggingSession, src/cocoa/dnd.js) takes the gesture from here:
1277
1270
  // the pointer's motion and release stop arriving and come back as
@@ -1279,7 +1272,34 @@ export class DragSession {
1279
1272
  // comes through that window's destination events, routed to this
1280
1273
  // session's live payload by `app._activeDrag`.
1281
1274
  const wnd = this.node.window;
1282
- if (typeof wnd?.beginDrag === 'function') {
1275
+ const nativeSession = typeof wnd?.beginDrag === 'function';
1276
+ // …and it owns the thread for the *whole* gesture, so anything this
1277
+ // dispatch schedules for later has no later: the `<popup dragPreview>`
1278
+ // an `onDragStart` setState renders would otherwise be created after
1279
+ // the drop. Discrete priority puts the update in the one lane a
1280
+ // backend can land by hand from inside a callback (`flushSyncWork`,
1281
+ // src/cocoa/app.js `_afterInput`). Where the frame clock keeps running,
1282
+ // the motion this arrived on is paced like any other and the update
1283
+ // keeps the priority the dispatcher gave it.
1284
+ const startEvent = () =>
1285
+ this.node.events.dispatch('DragStart', source, native, {
1286
+ types: this.types,
1287
+ action: this.currentAction,
1288
+ source: 'internal',
1289
+ screenX: (native.rootx ?? native.x) / this.node.events.scale,
1290
+ screenY: (native.rooty ?? native.y) / this.node.events.scale,
1291
+ });
1292
+ const ev = nativeSession
1293
+ ? runWithPriority(DiscreteEventPriority, startEvent)
1294
+ : startEvent();
1295
+ if (ev.defaultPrevented) {
1296
+ this._reset();
1297
+ return false;
1298
+ }
1299
+ this.phase = 'dragging';
1300
+ source.setStyleState(':dragging', true);
1301
+ this._setCursor('grab');
1302
+ if (nativeSession) {
1283
1303
  this._nativeSession = true;
1284
1304
  this.app._activeDrag = this;
1285
1305
  try {
@@ -1295,7 +1315,12 @@ export class DragSession {
1295
1315
  }
1296
1316
 
1297
1317
  /** `drag-session-moved` on a native session: the source's `onDrag`, in
1298
- * global device pixels, with whether a window of ours has accepted. */
1318
+ * global device pixels, with whether a window of ours has accepted.
1319
+ *
1320
+ * Discrete priority, like the start: this is the only news of the gesture
1321
+ * that arrives while the native session owns the thread, so the render it
1322
+ * schedules has to be landable from inside the callback. A preview that
1323
+ * follows the pointer is exactly a render per position. */
1299
1324
  nativeMoved(ev) {
1300
1325
  if (this.phase !== 'dragging' || !this._nativeSession) return;
1301
1326
  const s = this.node.scale;
@@ -1312,18 +1337,20 @@ export class DragSession {
1312
1337
  };
1313
1338
  const source = this.source;
1314
1339
  if (source && !source.destroyed && source.props.onDrag) {
1315
- callHandler(
1316
- source,
1317
- 'onDrag',
1318
- source.props.onDrag,
1319
- this.node.events._makeEvent('drag', native, source, {
1320
- types: this.types,
1321
- action: this.currentAction,
1322
- source: this.localSession ? 'internal' : 'external',
1323
- accepted: this.accepted,
1324
- screenX: rootX / this.node.events.scale,
1325
- screenY: rootY / this.node.events.scale,
1326
- }),
1340
+ runWithPriority(DiscreteEventPriority, () =>
1341
+ callHandler(
1342
+ source,
1343
+ 'onDrag',
1344
+ source.props.onDrag,
1345
+ this.node.events._makeEvent('drag', native, source, {
1346
+ types: this.types,
1347
+ action: this.currentAction,
1348
+ source: this.localSession ? 'internal' : 'external',
1349
+ accepted: this.accepted,
1350
+ screenX: rootX / this.node.events.scale,
1351
+ screenY: rootY / this.node.events.scale,
1352
+ }),
1353
+ ),
1327
1354
  );
1328
1355
  }
1329
1356
  }
@@ -1343,10 +1370,20 @@ export class DragSession {
1343
1370
  const rootY = Math.round((ev.y ?? 0) * s);
1344
1371
  const operation =
1345
1372
  ev.operation && ev.operation !== 'none' ? ev.operation : null;
1346
- this._end(
1347
- { x: rootX - origin.x, y: rootY - origin.y, rootx: rootX, rooty: rootY },
1348
- ev.dropped ? operation : null,
1349
- Boolean(ev.dropped),
1373
+ // the release, and the last callback before the thread comes back:
1374
+ // `onDragEnd` takes the preview down, and that is a discrete answer to
1375
+ // the button like any other
1376
+ runWithPriority(DiscreteEventPriority, () =>
1377
+ this._end(
1378
+ {
1379
+ x: rootX - origin.x,
1380
+ y: rootY - origin.y,
1381
+ rootx: rootX,
1382
+ rooty: rootY,
1383
+ },
1384
+ ev.dropped ? operation : null,
1385
+ Boolean(ev.dropped),
1386
+ ),
1350
1387
  );
1351
1388
  }
1352
1389
 
package/src/index.d.ts CHANGED
@@ -188,7 +188,17 @@ export interface RootOptions {
188
188
  * The Cocoa backend's knobs (docs/macos.md). `presenter` picks the frame
189
189
  * path: `'surface'` (the measured default — one bitmap per window, the
190
190
  * X11 paint machinery over an IOSurface swapchain) or `'layers'` (one
191
- * CALayer per drawn node, opt-in while it is measured).
191
+ * CALayer per drawn node, opt-in while it is measured). `promote` is the
192
+ * surface presenter's layer promotion: a plain `<box>` with a transition
193
+ * or a loop on its colour, border or radius gets a CALayer of its own
194
+ * above the bitmap for as long as it animates, and the render server
195
+ * draws the motion — no frames, and it keeps moving while the JS thread
196
+ * is busy. On by default where the bridge draws a layer's colour and a
197
+ * rastered one alike (`@windowkit/appkit` >= 0.5.1, which says so with
198
+ * `colorSpace()`; off on 0.5.0, where the two shades differed); `true`
199
+ * turns it on regardless, `false` keeps every animation on the frame
200
+ * clock.
201
+ * `REACT_X11_COCOA_PROMOTE=1` / `=0` say the same from the environment.
192
202
  * `frameInterval` is how often a scheduled frame may paint, in ms. By
193
203
  * default each window paces itself on the display it is on — 8.3ms on
194
204
  * a 120Hz panel, 16.7 on a 60Hz monitor, the screen's own refresh rate
@@ -208,6 +218,7 @@ export interface RootOptions {
208
218
  */
209
219
  cocoa?: {
210
220
  presenter?: 'surface' | 'layers';
221
+ promote?: boolean;
211
222
  frameInterval?: number;
212
223
  pumpInterval?: number;
213
224
  appName?: string;
package/src/nodes.js CHANGED
@@ -1913,6 +1913,10 @@ export class Node {
1913
1913
  this._floorMeasureMode = null;
1914
1914
  this.root = null; // owning WindowNode once attached
1915
1915
  this.hidden = false;
1916
+ // Composited on a layer of its own above the window's bitmap, by a
1917
+ // presenter that can (src/cocoa/promotion.js): the paint walk leaves a
1918
+ // hole where it is, and the presenter draws it — the `<glarea>` idiom.
1919
+ this._promoted = false;
1916
1920
  this.destroyed = false;
1917
1921
  // absolute rect within the owning window, filled by absolutize()
1918
1922
  this.abs = { x: 0, y: 0, width: 0, height: 0 };
@@ -2285,10 +2289,11 @@ export class Node {
2285
2289
 
2286
2290
  // --- the presenter's half of an animation ---------------------------------
2287
2291
  //
2288
- // Three feature-detected hooks on the window (src/cocoa/window.js, layers
2289
- // mode only): `animateNode(node, prop, entry)` answers true when the
2290
- // presenter will run the entry itself, `cancelNodeAnimation(node, prop)`
2291
- // stops what it runs for the property, and the presenter calls back
2292
+ // Two feature-detected hooks on the window (src/cocoa/window.js: the
2293
+ // layer presenter, and the surface presenter's layer promotion —
2294
+ // src/cocoa/promotion.js): `animateNode(node, prop, entry)` answers true
2295
+ // when the presenter will run the entry itself, `cancelNodeAnimation(node,
2296
+ // prop)` stops what it runs for the property, and the presenter calls back
2292
2297
  // through `_offloadEnded` / `_offloadDeclined` below. An entry the
2293
2298
  // presenter took is `offloaded`: it stays in `_anim` — so a retarget, a
2294
2299
  // loop-stop rule and `sameAnimation` all see it — but it contributes no
@@ -5315,6 +5320,7 @@ export class Node {
5315
5320
  ctx.clip();
5316
5321
  }
5317
5322
  for (const child of order) {
5323
+ if (child._promoted) continue; // on a layer of its own: a hole here
5318
5324
  if (child._offscreen()) continue;
5319
5325
  if (child._outsideDamage()) continue;
5320
5326
  child.paint(ctx);
@@ -10467,8 +10473,18 @@ export class WindowNode extends Scrollable(Node) {
10467
10473
  * and advertising lazily would race sources that cache the window list
10468
10474
  * at drag start. A window with no registered drop targets answers "not
10469
10475
  * accepting" once per entry instead (DropSession).
10476
+ *
10477
+ * The one exception is a `<popup dragPreview>`. It follows the pointer,
10478
+ * so for the whole gesture it is the frontmost window under it, and it
10479
+ * must never be what the drag is over. Where react-x11 picks the target
10480
+ * itself (src/dnd.js `topLevelAt`) it is skipped by name; where the OS
10481
+ * picks — AppKit routes a drag to the frontmost window registered for a
10482
+ * type it carries, and looks past one that registered none — the only
10483
+ * way to say so is to register nothing (#488). So a preview gets none of
10484
+ * this: no session, no registry entry, no property.
10470
10485
  */
10471
10486
  _initDnd() {
10487
+ if (this.props.dragPreview) return;
10472
10488
  const wnd = this.window;
10473
10489
  const X = this.app?.X;
10474
10490
  // A backend with drop machinery of its own (the cocoa backend's
@@ -11552,6 +11568,13 @@ export class WindowNode extends Scrollable(Node) {
11552
11568
  watchDesktopSettings(this.app, () => this._refreshLoops()),
11553
11569
  ];
11554
11570
  this._loopVisibilityChanged();
11571
+ // The window has a presenter now, which it did not when a loop declared
11572
+ // at mount started on the clock (`_setRoot` runs before `realize`):
11573
+ // every loop is asked again here, so one a presenter can take moves
11574
+ // over on the window's first frame rather than at the next swap that
11575
+ // happens to re-resolve its style. Where nothing can take it, the
11576
+ // second look at an unchanged declaration is a no-op.
11577
+ this._refreshLoops();
11555
11578
  }
11556
11579
 
11557
11580
  _unwatchLoops() {
@@ -11725,8 +11748,19 @@ export class WindowNode extends Scrollable(Node) {
11725
11748
  // A retained presenter keeps a per-node diff instead of damage rects,
11726
11749
  // and this is the one channel every change already announces itself on
11727
11750
  // (docs/macos.md §"One renderer, two presenters"). Feature-detected: an
11728
- // ntk window has no ear here and the X11 path is byte-identical.
11729
- this.window?.noteInvalidate?.(damage, layoutChanged, reason);
11751
+ // ntk window has no ear here and the X11 path is byte-identical. A
11752
+ // presenter that answers `true` has taken the claim onto a layer of its
11753
+ // own (src/cocoa/promotion.js): the bitmap owes nothing for it, and the
11754
+ // frame that is still owed — for the presenter's half, `prepareFrame` —
11755
+ // paints nothing unless something else claims.
11756
+ const taken =
11757
+ this.window?.noteInvalidate?.(damage, layoutChanged, reason) === true;
11758
+ if (taken && !layoutChanged) {
11759
+ this._damage ??= [];
11760
+ this.needsPaint = true;
11761
+ this._scheduleFrame();
11762
+ return;
11763
+ }
11730
11764
  if (layoutChanged) {
11731
11765
  this.needsLayout = true;
11732
11766
  // The content floors are measured from the tree, so anything that
@@ -11832,6 +11866,11 @@ export class WindowNode extends Scrollable(Node) {
11832
11866
  this._damage = addDamageRect(this._damage, bounds, this._damageRectCap());
11833
11867
  }
11834
11868
  this.needsPaint = true;
11869
+ this._scheduleFrame();
11870
+ }
11871
+
11872
+ /** A frame, on the window's clock. */
11873
+ _scheduleFrame() {
11835
11874
  // Recorded before the `_scheduled` gate, not inside it: the debt is
11836
11875
  // "this window has damage", which a discrete event may pay off early
11837
11876
  // (see frames.js). Tying it to whether a callback is outstanding would
@@ -11959,6 +11998,13 @@ export class WindowNode extends Scrollable(Node) {
11959
11998
  for (const node of this._reflowed) node._reflowBefore = null;
11960
11999
  this._reflowed.clear();
11961
12000
  }
12001
+ // A presenter compositing part of the tree on layers of its own — the
12002
+ // surface presenter's promoted nodes (src/cocoa/promotion.js) — gets
12003
+ // its word in here: after layout, so it sees where everything landed,
12004
+ // and before the damage is taken, so a node it moves onto or off a
12005
+ // layer claims the bitmap under it in this very frame. Feature-detected
12006
+ // like `presentFrame`; an ntk window has no such half.
12007
+ this.window.prepareFrame?.(this, layoutRan);
11962
12008
  // any node this pass laid out may be what an open popup is anchored to
11963
12009
  if (layoutRan) this._notifyAnchorChange();
11964
12010
  // after layout (the claims above included), before the damage is taken:
@@ -12504,6 +12550,11 @@ export class WindowNode extends Scrollable(Node) {
12504
12550
  if (!check(child)) return false;
12505
12551
  continue;
12506
12552
  }
12553
+ // A node on a layer of its own (src/cocoa/promotion.js) has no
12554
+ // pixels in the bitmap the band is cut from, so nothing of it can
12555
+ // be dragged along — a pulsing toast over a list is what promotion
12556
+ // is for, and this is the half of it that keeps the pan a blit.
12557
+ if (child._promoted) continue;
12507
12558
  if (rectsOverlap(child._subtreeBounds(), vp)) return false;
12508
12559
  }
12509
12560
  return true;
@@ -12706,6 +12757,14 @@ export class WindowNode extends Scrollable(Node) {
12706
12757
  this._lastReasons = EMPTY_REASONS;
12707
12758
  }
12708
12759
  if (damage === FULL_DAMAGE || !damage) return null;
12760
+ // an empty list: every claim this frame made was answered on a layer of
12761
+ // its own (`invalidate`, the presenter's `true`), and the bitmap paints
12762
+ // nothing — which is not the same as nothing having been claimed
12763
+ if (damage.length === 0) {
12764
+ this._lastDamageRects = [];
12765
+ this._lastDamage = { x: 0, y: 0, width: 0, height: 0 };
12766
+ return [];
12767
+ }
12709
12768
  const rects = [];
12710
12769
  for (const claimed of damage) {
12711
12770
  const clamped = this._clampDamage(claimed, width, height);
@@ -436,8 +436,11 @@ export interface WindowProps
436
436
  xi2?: boolean | 'auto';
437
437
  /**
438
438
  * Mark this window as a drag preview: the drag router never treats it as
439
- * the window under the pointer, so a `<popup dragPreview>` can follow the
440
- * pointer without swallowing its own drag. See `useDragSource`.
439
+ * the window under the pointer, and it is never a drop destination on
440
+ * the cocoa backend, where AppKit picks the destination, that is what
441
+ * lets a drop reach the window beneath it. A `<popup dragPreview>` can
442
+ * therefore follow the pointer without swallowing its own drag. Read when
443
+ * the window is created. See `useDragSource`.
441
444
  */
442
445
  dragPreview?: boolean;
443
446
  /**