react-x11 2.5.0 → 2.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.5.0",
3
+ "version": "2.6.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -66,7 +66,8 @@
66
66
  "docs:dev": "npm --prefix website start",
67
67
  "docs:build": "npm --prefix website run build",
68
68
  "docs:test": "npm --prefix website test",
69
- "bench:presenters": "node --import tsx scripts/bench/presenters.js"
69
+ "bench:presenters": "node --import tsx scripts/bench/presenters.js",
70
+ "bench:touched": "node scripts/bench/touched.js"
70
71
  },
71
72
  "repository": {
72
73
  "type": "git",
@@ -90,7 +91,7 @@
90
91
  "yoga-layout": "^3.2.1"
91
92
  },
92
93
  "optionalDependencies": {
93
- "@windowkit/appkit": "^0.3.0",
94
+ "@windowkit/appkit": "^0.4.0",
94
95
  "dbus-native": "^0.15.1",
95
96
  "x11-dri": "^0.7.0"
96
97
  },
package/src/Reconciler.js CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  flushWindowMaps,
32
32
  flushWindowRestacks,
33
33
  windowAttributes,
34
+ setTextStripBelow,
34
35
  } from './nodes.js';
35
36
  import { hasDropProps } from './dnd.js';
36
37
  import { AppProvider } from './appcontext.js';
@@ -790,6 +791,11 @@ export async function createRoot(options = {}) {
790
791
  // anything from disk.
791
792
  beginCompose(app, rest.compose);
792
793
 
794
+ // Under what size a paragraph is painted as a strip of its ink instead
795
+ // of as glyphs (nodes.js, `TextNode._paintsStrip`): six logical pixels by
796
+ // default, 0 for glyphs at every size.
797
+ setTextStripBelow(app, rest.textStripBelow);
798
+
793
799
  // Whether a subtree coming back out of hiding — a `<Suspense>` boundary
794
800
  // resolving, an `<Activity>` shown again — takes the keyboard back with it
795
801
  // (src/events.js, `subtreeRevealed`). On by default; `false` is the
package/src/cocoa/app.js CHANGED
@@ -29,7 +29,18 @@ import { CocoaWindow } from './window.js';
29
29
  import { decodeKey, modifierMask } from './keymap.js';
30
30
  import { loadNative } from './native.js';
31
31
 
32
+ // The frame interval: how often a scheduled frame may paint, in ms. The
33
+ // default is the period of the display the window is on — `listScreens`
34
+ // reports each panel's `fps` (NSScreen.maximumFramesPerSecond, bridge 0.4),
35
+ // so a 120Hz panel paints every 8.3ms and a 60Hz monitor every 16.7 —
36
+ // and `createRoot({ cocoa: { frameInterval } })` overrides it for every
37
+ // window. 16 stands in where the OS cannot say (`fps: 0`) and under a
38
+ // bridge that does not report it: a 60Hz floor every Mac clears.
32
39
  const RAF_INTERVAL_MS = 16;
40
+ const PUMP_INTERVAL_MS = 8;
41
+ // How early a pump tick may take a frame that is not quite due, in ms — the
42
+ // drift of a timer, not a fraction of the pump (`_frameDue`).
43
+ const FRAME_SLACK_MS = 1;
33
44
 
34
45
  export class CocoaApp {
35
46
  constructor(native, options = {}) {
@@ -37,8 +48,18 @@ export class CocoaApp {
37
48
  this.options = options;
38
49
  this._windows = new Map(); // windowNumber -> CocoaWindow
39
50
  this._grabWindow = null;
40
- this._rafQueue = [];
51
+ this._rafQueue = []; // [{ cb, wnd }]
52
+ // the app's own frame clock, for a frame no window owns (a pane's)
41
53
  this._rafLast = 0;
54
+ // an explicit interval applies to every window; null means each
55
+ // window paces itself on its own display (`frameIntervalFor`)
56
+ this._frameInterval = options.cocoa?.frameInterval ?? null;
57
+ this._pumpInterval = PUMP_INTERVAL_MS;
58
+ // the one-shot that runs a frame due between two pump ticks, and when
59
+ // it is due (`_armFrameTimer`)
60
+ this._frameTimer = null;
61
+ this._frameTimerAt = 0;
62
+ this._geometryFlushQueued = false;
42
63
  this._shadowStale = new Set();
43
64
  this._pump = null;
44
65
  this._closed = false;
@@ -243,6 +264,35 @@ export class CocoaApp {
243
264
  this._windows.set(wnd.windowNumber, wnd);
244
265
  }
245
266
 
267
+ /**
268
+ * How often `wnd` may paint, in ms: the explicit `frameInterval` when the
269
+ * root was given one, else the period of the screen under the window's
270
+ * centre — the display's own rate is the only honest cadence, and on a
271
+ * desk with a 120Hz panel and a 60Hz monitor the two windows differ. A
272
+ * window on no screen (mid-drag between two, or off the edge) and a
273
+ * screen the OS reports no rate for take the primary's, then 16ms.
274
+ */
275
+ frameIntervalFor(wnd) {
276
+ if (this._frameInterval != null) return this._frameInterval;
277
+ const s = this.scale;
278
+ const screens = this._screens ?? [];
279
+ let screen = null;
280
+ if (wnd) {
281
+ const cx = (wnd.x + wnd.width / 2) / s;
282
+ const cy = (wnd.y + wnd.height / 2) / s;
283
+ screen =
284
+ screens.find(
285
+ (sc) =>
286
+ cx >= sc.x &&
287
+ cx < sc.x + sc.width &&
288
+ cy >= sc.y &&
289
+ cy < sc.y + sc.height,
290
+ ) ?? null;
291
+ }
292
+ const fps = screen?.fps || screens[0]?.fps || 0;
293
+ return fps > 0 ? 1000 / fps : RAF_INTERVAL_MS;
294
+ }
295
+
246
296
  /**
247
297
  * The pane's end of the frame channel (childmain hands it over,
248
298
  * feature-detected so the X11 pane path never notices): geometry and
@@ -277,8 +327,9 @@ export class CocoaApp {
277
327
 
278
328
  // --- the pump ------------------------------------------------------------
279
329
 
280
- start({ pumpInterval = 8 } = {}) {
330
+ start({ pumpInterval = PUMP_INTERVAL_MS } = {}) {
281
331
  if (this._pump) return;
332
+ this._pumpInterval = pumpInterval;
282
333
  const native = this._native;
283
334
  // A pane process has no NSApplication to pump — no windows, no events,
284
335
  // no dock presence. Its loop is frames and presents only.
@@ -292,6 +343,7 @@ export class CocoaApp {
292
343
  native.initApp();
293
344
  native.setBackendEventCallback((ev) => this._route(ev));
294
345
  this._pump = setInterval(() => {
346
+ this._endLiveResizes();
295
347
  native.pump2(); // flushes the previous tick's CATransaction
296
348
  if (this._shadowStale.size) {
297
349
  for (const wnd of this._shadowStale) {
@@ -304,27 +356,129 @@ export class CocoaApp {
304
356
  }, pumpInterval);
305
357
  }
306
358
 
307
- _requestFrame(cb) {
308
- this._rafQueue.push(cb);
359
+ /**
360
+ * A pump tick means no modal loop owns the thread, so no window is being
361
+ * resized live right now: the flag the delegate set on the last tick of a
362
+ * drag comes off here, ahead of the frames that tick, and the catch-up
363
+ * frame a deferred layout owes (nodes.js) runs on this very tick.
364
+ */
365
+ _endLiveResizes() {
366
+ for (const wnd of this._windows.values()) wnd.liveResizing = false;
367
+ }
368
+
369
+ _requestFrame(cb, wnd = null) {
370
+ this._rafQueue.push({ cb, wnd });
309
371
  return this._rafQueue.length;
310
372
  }
311
373
 
374
+ /**
375
+ * Whether `clock` — a window, or the app for a frame no window owns —
376
+ * is due a frame at `now`, and if so, stamps it.
377
+ *
378
+ * The clock keeps the display's period, not the pump's: a frame that ran
379
+ * is stamped one interval after the last one was due rather than at the
380
+ * moment it happened to run, so a tick that took it a little early or a
381
+ * timer that fired a little late moves nothing — the next frame is still
382
+ * due where the display's next refresh is. A frame that arrives more than
383
+ * an interval late (a slow one) re-anchors the clock instead of owing the
384
+ * frames it missed.
385
+ *
386
+ * The gate is a millisecond of slack under the interval, which is a
387
+ * timer's drift and no more. It used to be half a pump, so that a frame
388
+ * would land on the first tick at or after the interval instead of
389
+ * alternating between the tick before and the one after; that quantized
390
+ * the period to the pump — a 75Hz monitor's 13.3ms gate of 9.3 fell on
391
+ * the 16ms tick, and the window painted at 62.5fps. A frame due between
392
+ * two ticks now gets a timer of its own (`_armFrameTimer`), and the
393
+ * period is the display's whatever the pump's cadence.
394
+ */
395
+ _frameDue(clock, now) {
396
+ const interval =
397
+ clock === this ? this.frameIntervalFor(null) : clock._frameInterval;
398
+ const since = now - clock._rafLast;
399
+ if (since < interval - FRAME_SLACK_MS) return false;
400
+ clock._rafLast = since < 2 * interval ? clock._rafLast + interval : now;
401
+ return true;
402
+ }
403
+
404
+ /** How long until `clock` is due, from `now`. */
405
+ _frameWait(clock, now) {
406
+ const interval =
407
+ clock === this ? this.frameIntervalFor(null) : clock._frameInterval;
408
+ return interval - FRAME_SLACK_MS - (now - clock._rafLast);
409
+ }
410
+
411
+ /**
412
+ * A frame that falls between two pump ticks gets a tick of its own: a
413
+ * one-shot timer at the moment it is due, which runs the frame queue and
414
+ * presents what it painted. Only when the next pump tick would be late
415
+ * for it — a frame due after that tick waits for the tick, which decides
416
+ * again. One timer at a time, at the soonest of what is owed; a pump tick
417
+ * that comes first runs whatever is due and re-arms for the rest.
418
+ *
419
+ * The timer costs the frame nothing the tick does not: a present commits
420
+ * its own transaction (the bridge flushes on the flip), so what is
421
+ * painted here is on glass without waiting for the next pump. What the
422
+ * tick alone still does is pump AppKit's events, which is what
423
+ * `pumpInterval` stays the cadence of.
424
+ */
425
+ _armFrameTimer(wait, now) {
426
+ if (!(wait > 0 && wait < this._pumpInterval)) return;
427
+ const at = now + wait;
428
+ if (this._frameTimer) {
429
+ if (at >= this._frameTimerAt) return;
430
+ clearTimeout(this._frameTimer);
431
+ }
432
+ this._frameTimerAt = at;
433
+ this._frameTimer = setTimeout(
434
+ () => {
435
+ this._frameTimer = null;
436
+ if (this._closed) return;
437
+ this._tickFrames();
438
+ this._presentAll();
439
+ },
440
+ Math.max(1, Math.round(wait)),
441
+ );
442
+ }
443
+
312
444
  _tickFrames() {
313
445
  if (!this._rafQueue.length) return;
314
- const now = Date.now();
315
- if (now - this._rafLast < RAF_INTERVAL_MS) return;
316
- this._rafLast = now;
446
+ const now = performance.now();
447
+ // Each window keeps its own clock, so a window on a 120Hz panel paints
448
+ // every refresh while one on a 60Hz monitor paints every other pump
449
+ // tick. Decided once per clock per tick: every frame a window queued
450
+ // runs when it is due, not just the first.
451
+ const due = new Map();
317
452
  const queue = this._rafQueue;
318
453
  this._rafQueue = [];
319
- for (const cb of queue) {
454
+ let soonest = Infinity;
455
+ for (const entry of queue) {
456
+ const clock = entry.wnd ?? this;
457
+ if (!due.has(clock)) due.set(clock, this._frameDue(clock, now));
458
+ if (!due.get(clock)) {
459
+ this._rafQueue.push(entry);
460
+ soonest = Math.min(soonest, this._frameWait(clock, now));
461
+ continue;
462
+ }
463
+ // A window nobody can see owes no frame: its callback waits here until
464
+ // it is back on glass, and the damage it answers accumulates on the
465
+ // node — one catch-up frame then, instead of a full paint per tick
466
+ // into a backing store no one reads. `_visible` is the window's own
467
+ // rule (mapped, not ordered out, not miniaturized, not entirely
468
+ // behind another application's window).
469
+ if (entry.wnd && !entry.wnd._visible()) {
470
+ this._rafQueue.push(entry);
471
+ continue;
472
+ }
320
473
  try {
321
- cb(now);
474
+ entry.cb(now);
322
475
  } catch (err) {
323
476
  queueMicrotask(() => {
324
477
  throw err;
325
478
  });
326
479
  }
327
480
  }
481
+ this._armFrameTimer(soonest, now);
328
482
  }
329
483
 
330
484
  _presentAll() {
@@ -366,6 +520,8 @@ export class CocoaApp {
366
520
  return this._routeFocus(ev, 'focus');
367
521
  case 'window-blur':
368
522
  return this._routeFocus(ev, 'blur');
523
+ case 'window-occlusion':
524
+ return this._routeOcclusion(ev);
369
525
  case 'menu-activate':
370
526
  this._activeGlobalMenu?.activate(ev.id);
371
527
  return this._afterInput();
@@ -459,6 +615,13 @@ export class CocoaApp {
459
615
  smooth: Boolean(ev.precise),
460
616
  source: ev.precise ? 'valuator' : 'button',
461
617
  });
618
+ // Painted now, like a press. On X11 the wheel is paced on the frame
619
+ // clock because ntk coalesces a touchpad's dozens of reports per frame
620
+ // into one event; AppKit already delivers scroll events at the
621
+ // display's rate, so answering each one is answering once per refresh
622
+ // — and answering it on the next frame tick instead was a 15ms median
623
+ // between the notch and the scroll, most of a refresh period of nothing.
624
+ this._afterInput();
462
625
  }
463
626
 
464
627
  _routeKey(ev) {
@@ -499,9 +662,31 @@ export class CocoaApp {
499
662
  // handler returns, and the pump that would paint it is the thing the
500
663
  // modal loop stalled. A second flush queued BEHIND that commit is what
501
664
  // lets an open menu resize with the drag instead of on release.
502
- queueMicrotask(() => {
503
- if (!this._closed) this._afterInput();
504
- });
665
+ //
666
+ // One, not one per event: inside the modal loop no microtask runs until
667
+ // the drag ends, so a drag's every tick queued another — and they all
668
+ // ran on the release, each finding the frame the previous one had just
669
+ // paid. Coalesced, the release owes at most one flush.
670
+ if (!this._geometryFlushQueued) {
671
+ this._geometryFlushQueued = true;
672
+ queueMicrotask(() => {
673
+ this._geometryFlushQueued = false;
674
+ if (!this._closed) this._afterInput();
675
+ });
676
+ }
677
+ }
678
+
679
+ /**
680
+ * `windowDidChangeOcclusionState`: `visible` is "some pixel of the window
681
+ * is on glass". Off, the window's frames wait in the queue and its
682
+ * present waits with them (`CocoaWindow._visible`); on, the next pump
683
+ * tick runs the catch-up frame and puts it on glass — no early flush,
684
+ * since nothing was asked for and the pump is at most one interval away.
685
+ */
686
+ _routeOcclusion(ev) {
687
+ const wnd = this._window(ev);
688
+ if (!wnd || wnd.destroyed) return;
689
+ wnd._occluded = ev.visible === false;
505
690
  }
506
691
 
507
692
  _routeClose(ev) {
@@ -528,6 +713,8 @@ export class CocoaApp {
528
713
  this._closed = true;
529
714
  if (this._pump) clearInterval(this._pump);
530
715
  this._pump = null;
716
+ if (this._frameTimer) clearTimeout(this._frameTimer);
717
+ this._frameTimer = null;
531
718
  this._cocoaGL?.destroy();
532
719
  this._cocoaGL = null;
533
720
  this._native.setBackendEventCallback(null);
@@ -536,6 +723,46 @@ export class CocoaApp {
536
723
  }
537
724
  }
538
725
 
726
+ /**
727
+ * `listScreens()` turned into the screen layout `src/screens.js` publishes.
728
+ *
729
+ * **One scale for every screen, and it is the app's.** macOS lays all the
730
+ * displays out in a single global point space, and `app.scale` is this
731
+ * app's points-to-device-pixels factor for the whole of it — window
732
+ * origins, event coordinates, these rects. Converting a 1x external
733
+ * display by *its own* 1 while windows on it still report `points * 2`
734
+ * would put the monitor somewhere no window ever is, and `monitorAt()`
735
+ * would answer with the wrong head. (What backing scale a window on a
736
+ * mixed-DPI desk should raster at is a real and separate question; the
737
+ * layout is not where it is answered.)
738
+ *
739
+ * **A usable rect per monitor.** `NSScreen.visibleFrame` is per screen —
740
+ * that display's own menu bar and Dock taken off — so each monitor carries
741
+ * its `visible` and `usable()` takes it as a rect. Publishing only the
742
+ * primary's, the way `_NET_WORKAREA` forces on X11, applied the primary's
743
+ * *width* as a bound to every other head: a second display wider than the
744
+ * built-in had its right edge pulled in by the difference, and every
745
+ * anchored popup that reached past it was clamped back (issue #453).
746
+ */
747
+ export function screenLayout(screens, scale) {
748
+ const rect = (r) => ({
749
+ x: Math.round(r.x * scale),
750
+ y: Math.round(r.y * scale),
751
+ width: Math.round(r.width * scale),
752
+ height: Math.round(r.height * scale),
753
+ });
754
+ const primary = screens?.[0];
755
+ return {
756
+ monitors: (screens ?? []).map((screen) => ({
757
+ ...rect(screen),
758
+ ...(screen.visible ? { visible: rect(screen.visible) } : null),
759
+ })),
760
+ // Still published for `useScreens().workArea`, which is one rect for
761
+ // the desktop by definition; the primary's is the closest macOS has.
762
+ workArea: primary?.visible ? rect(primary.visible) : null,
763
+ };
764
+ }
765
+
539
766
  /**
540
767
  * Build the app and seed the platform stores the way the mock seeds them —
541
768
  * `beginScale`/`beginScreens`/`beginCompositing` find a session already
@@ -546,25 +773,7 @@ export async function createCocoaApp(options = {}) {
546
773
  const app = new CocoaApp(native, options);
547
774
 
548
775
  setScaleForTests(app, app.scale, 'cocoa');
549
- const s = app.scale;
550
- const monitors = app._screens.map((screen) => ({
551
- x: Math.round(screen.x * s),
552
- y: Math.round(screen.y * s),
553
- width: Math.round(screen.width * s),
554
- height: Math.round(screen.height * s),
555
- }));
556
- const primary = app._screens[0];
557
- setScreensForTests(app, {
558
- monitors,
559
- workArea: primary
560
- ? {
561
- x: Math.round(primary.visible.x * s),
562
- y: Math.round(primary.visible.y * s),
563
- width: Math.round(primary.visible.width * s),
564
- height: Math.round(primary.visible.height * s),
565
- }
566
- : null,
567
- });
776
+ setScreensForTests(app, screenLayout(app._screens, app.scale));
568
777
  setCompositingForTests(app, true);
569
778
 
570
779
  app.start(options.cocoa ?? {});