react-x11 2.18.4 → 2.18.5

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.18.4",
3
+ "version": "2.18.5",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -324,6 +324,17 @@ export class BackendContext2D {
324
324
  // chunked stroke leaves only its last chunk behind)
325
325
  this._cmds = [];
326
326
  this._pathStale = false;
327
+ // A bridge that takes a whole path in one call (`ctxPath`, the Windows
328
+ // bridge's) is handed it that way when something paints it: the verbs
329
+ // below only record. A graph's edges are tens of thousands of `lineTo`s
330
+ // a frame, and a call across the boundary for each was milliseconds of
331
+ // every frame spent on the crossing. A bridge without it is handed the
332
+ // path a point at a time, as it is built, exactly as before.
333
+ // Asked with `in` as well: a test's bridge that answers every name with
334
+ // a no-op must not look like one that takes paths whole.
335
+ this._bulkPaths =
336
+ 'ctxPath' in native && typeof native.ctxPath === 'function';
337
+ this._pathBuf = null;
327
338
  this._strokeChunking = !NO_STROKE_CHUNKING;
328
339
  }
329
340
 
@@ -670,6 +681,21 @@ export class BackendContext2D {
670
681
  _emit(surface, from, to) {
671
682
  const c = this._cmds;
672
683
  const n = this._native;
684
+ if (this._bulkPaths) {
685
+ // one call, through a buffer kept between paths — the record is the
686
+ // same stream the bridge parses, arguments and all
687
+ const count = to - from;
688
+ if (count <= 0) return;
689
+ let buf = this._pathBuf;
690
+ if (!buf || buf.length < count) {
691
+ buf = this._pathBuf = new Float64Array(
692
+ Math.max(count, (buf?.length ?? 512) * 2),
693
+ );
694
+ }
695
+ for (let i = 0; i < count; i++) buf[i] = c[from + i];
696
+ n.ctxPath(surface, buf.subarray(0, count));
697
+ return;
698
+ }
673
699
  for (let i = from; i < to;) {
674
700
  const op = c[i];
675
701
  const a = i + 1;
@@ -720,26 +746,44 @@ export class BackendContext2D {
720
746
 
721
747
  beginPath() {
722
748
  this._cmds.length = 0;
749
+ if (this._bulkPaths) {
750
+ this._s();
751
+ // uploaded, empty or not, when something paints it
752
+ this._pathStale = true;
753
+ return;
754
+ }
723
755
  this._pathStale = false;
724
756
  this._native.ctxBeginPath(this._s());
725
757
  }
726
758
 
759
+ /**
760
+ * The surface to add a path command to now, or null where the path is
761
+ * only recorded and handed over whole when painted (`_bulkPaths`). Either
762
+ * way a fresh surface drops the path first, as `_s` has always done.
763
+ */
764
+ _pathFor() {
765
+ if (!this._bulkPaths) return this._path();
766
+ this._s();
767
+ this._pathStale = true;
768
+ return null;
769
+ }
770
+
727
771
  moveTo(x, y) {
728
- const surface = this._path();
772
+ const surface = this._pathFor();
729
773
  this._cmds.push(P_MOVE, x, y);
730
- this._native.ctxMoveTo(surface, x, y);
774
+ if (surface !== null) this._native.ctxMoveTo(surface, x, y);
731
775
  }
732
776
 
733
777
  lineTo(x, y) {
734
- const surface = this._path();
778
+ const surface = this._pathFor();
735
779
  this._cmds.push(P_LINE, x, y);
736
- this._native.ctxLineTo(surface, x, y);
780
+ if (surface !== null) this._native.ctxLineTo(surface, x, y);
737
781
  }
738
782
 
739
783
  rect(x, y, w, h) {
740
- const surface = this._path();
784
+ const surface = this._pathFor();
741
785
  this._cmds.push(P_RECT, x, y, w, h);
742
- this._native.ctxRect(surface, x, y, w, h);
786
+ if (surface !== null) this._native.ctxRect(surface, x, y, w, h);
743
787
  }
744
788
 
745
789
  roundRect(x, y, w, h, radii) {
@@ -750,7 +794,7 @@ export class BackendContext2D {
750
794
  else if (r.length === 3) r = [r[0], r[1], r[2], r[1]];
751
795
  const cap = Math.min(Math.abs(w) / 2, Math.abs(h) / 2);
752
796
  const clamp = (v) => Math.max(0, Math.min(Number(v) || 0, cap));
753
- const surface = this._path();
797
+ const surface = this._pathFor();
754
798
  const [r0, r1, r2, r3] = [
755
799
  clamp(r[0]),
756
800
  clamp(r[1]),
@@ -758,37 +802,40 @@ export class BackendContext2D {
758
802
  clamp(r[3]),
759
803
  ];
760
804
  this._cmds.push(P_ROUND, x, y, w, h, r0, r1, r2, r3);
761
- this._native.ctxRoundRect(surface, x, y, w, h, r0, r1, r2, r3);
805
+ if (surface !== null)
806
+ this._native.ctxRoundRect(surface, x, y, w, h, r0, r1, r2, r3);
762
807
  }
763
808
 
764
809
  arc(x, y, radius, start, end, anticlockwise = false) {
765
- const surface = this._path();
810
+ const surface = this._pathFor();
766
811
  this._cmds.push(P_ARC, x, y, radius, start, end, anticlockwise ? 1 : 0);
767
- this._native.ctxArc(surface, x, y, radius, start, end, anticlockwise);
812
+ if (surface !== null)
813
+ this._native.ctxArc(surface, x, y, radius, start, end, anticlockwise);
768
814
  }
769
815
 
770
816
  ellipse(x, y, rx, ry) {
771
- const surface = this._path();
817
+ const surface = this._pathFor();
772
818
  this._cmds.push(P_ELLIPSE, x, y, rx, ry);
773
- this._native.ctxEllipse(surface, x, y, rx, ry);
819
+ if (surface !== null) this._native.ctxEllipse(surface, x, y, rx, ry);
774
820
  }
775
821
 
776
822
  bezierCurveTo(c1x, c1y, c2x, c2y, x, y) {
777
- const surface = this._path();
823
+ const surface = this._pathFor();
778
824
  this._cmds.push(P_CURVE, c1x, c1y, c2x, c2y, x, y);
779
- this._native.ctxCurveTo(surface, c1x, c1y, c2x, c2y, x, y);
825
+ if (surface !== null)
826
+ this._native.ctxCurveTo(surface, c1x, c1y, c2x, c2y, x, y);
780
827
  }
781
828
 
782
829
  quadraticCurveTo(cx, cy, x, y) {
783
- const surface = this._path();
830
+ const surface = this._pathFor();
784
831
  this._cmds.push(P_QUAD, cx, cy, x, y);
785
- this._native.ctxQuadTo(surface, cx, cy, x, y);
832
+ if (surface !== null) this._native.ctxQuadTo(surface, cx, cy, x, y);
786
833
  }
787
834
 
788
835
  closePath() {
789
- const surface = this._path();
836
+ const surface = this._pathFor();
790
837
  this._cmds.push(P_CLOSE);
791
- this._native.ctxClosePath(surface);
838
+ if (surface !== null) this._native.ctxClosePath(surface);
792
839
  }
793
840
 
794
841
  // --- painting ------------------------------------------------------------
package/src/gloverlay.js CHANGED
@@ -114,6 +114,18 @@ export function overlayRefusal(app) {
114
114
  return (app && refusals.get(app)) ?? null;
115
115
  }
116
116
 
117
+ /**
118
+ * A backend saying nothing can be drawn over a GL surface on this app, and
119
+ * why: one whose child windows are not X windows, and that has no
120
+ * composited pane either — on Windows a window with a parent is a GL
121
+ * surface of its own (src/win32/glarea.js), and a bridge without layers has
122
+ * nothing else to offer. Before the first render, like `beginGlOverlay`, so
123
+ * `useSupports('glOverlay')` never changes its answer.
124
+ */
125
+ export function refuseOverlay(app, reason) {
126
+ if (app) refusals.set(app, reason);
127
+ }
128
+
117
129
  /**
118
130
  * Is this an X server where nothing can be drawn over a GL surface? Asked
119
131
  * in `createRoot`, with the other startup probes and before the first
@@ -654,35 +666,46 @@ export class GlOverlay {
654
666
  _paintPane(pane, passes) {
655
667
  const area = this.area;
656
668
  const root = area.root;
657
- const ctx = pane.context();
658
- if (!ctx || !root) return;
669
+ if (!root) return;
659
670
  // an opaque pane is filled with what the surface starts its frames from;
660
671
  // a composited one is cleared, and shows the frame itself
661
672
  const ground = pane.transparent ? null : groundOf(area.props);
662
- ctx.save();
663
- try {
664
- ctx.translate(-pane.rect.x, -pane.rect.y);
665
- for (const pass of passes) {
666
- ctx.save();
667
- try {
668
- ctx.beginPath();
669
- ctx.rect(pass.x, pass.y, pass.width, pass.height);
670
- ctx.clip();
671
- if (ground) {
672
- ctx.fillStyle = ground;
673
- ctx.fillRect(pass.x, pass.y, pass.width, pass.height);
674
- } else {
675
- ctx.clearRect(pass.x, pass.y, pass.width, pass.height);
676
- }
677
- root._paintDamage = pass;
678
- area._paintChildren(ctx);
679
- } finally {
680
- root._paintDamage = null;
681
- ctx.restore();
673
+ // one pass, drawn by a context in window coordinates
674
+ const paintPass = (ctx, pass) => {
675
+ ctx.save();
676
+ try {
677
+ ctx.beginPath();
678
+ ctx.rect(pass.x, pass.y, pass.width, pass.height);
679
+ ctx.clip();
680
+ if (ground) {
681
+ ctx.fillStyle = ground;
682
+ ctx.fillRect(pass.x, pass.y, pass.width, pass.height);
683
+ } else {
684
+ ctx.clearRect(pass.x, pass.y, pass.width, pass.height);
682
685
  }
686
+ root._paintDamage = pass;
687
+ area._paintChildren(ctx);
688
+ } finally {
689
+ root._paintDamage = null;
690
+ ctx.restore();
691
+ }
692
+ };
693
+ if (typeof pane.wnd.paintPasses === 'function') {
694
+ // A pane drawn a rect at a time — a DirectComposition surface hands
695
+ // out a context per `BeginDraw`, valid until `EndDraw` — opens each
696
+ // pass itself (src/win32/overlay.js), the way such a window takes its
697
+ // frame through `presentFrame`.
698
+ pane.wnd.paintPasses(passes, paintPass);
699
+ } else {
700
+ const ctx = pane.context();
701
+ if (!ctx) return;
702
+ ctx.save();
703
+ try {
704
+ ctx.translate(-pane.rect.x, -pane.rect.y);
705
+ for (const pass of passes) paintPass(ctx, pass);
706
+ } finally {
707
+ ctx.restore();
683
708
  }
684
- } finally {
685
- ctx.restore();
686
709
  }
687
710
  // A layer's contents are a copy the bitmap is pushed to; an X window's
688
711
  // backing store is blitted by ntk on its own, from the paint above
@@ -314,8 +314,16 @@ export class WindowInvalidate {
314
314
  // the whole reason this is not just `needsLayout`: a scroll moves an
315
315
  // offset applied during `absolutize` and leaves every yoga node exactly
316
316
  // as it was, at input rate, on the biggest trees in any app.
317
- if (reason !== 'scroll') this._floorsDirty = true;
318
- if (reason !== 'scroll' && reason !== 'resize') {
317
+ //
318
+ // Nor does a box that only moved (`'position'`, from `applyProps`):
319
+ // absolutely positioned and sized by its own width and height, it is
320
+ // out of every ancestor's flow and sizes nothing inside it by its
321
+ // insets. A card dragged across a graph — or a thousand of them
322
+ // panned — was a full measurement of the content floors a step.
323
+ if (reason !== 'scroll' && reason !== 'position') {
324
+ this._floorsDirty = true;
325
+ }
326
+ if (reason !== 'scroll' && reason !== 'resize' && reason !== 'position') {
319
327
  this._floorsContentDirty = true;
320
328
  }
321
329
  }
package/src/nodes/node.js CHANGED
@@ -605,6 +605,18 @@ export class Node {
605
605
  // which is what keeps a commit from widening the region to every node
606
606
  // it touched.
607
607
  if (layoutChanged) {
608
+ const moved = onlyInsetsChanged(style, prevStyle);
609
+ // An absolutely positioned box of a set size that moved and changed
610
+ // nothing else is out of its ancestors' flow and sized by its width
611
+ // and height rather than its insets, so no content floor anywhere
612
+ // moved with it (src/nodes/invalidate.js, `'position'`).
613
+ const reason =
614
+ moved &&
615
+ style.position === 'absolute' &&
616
+ typeof style.width === 'number' &&
617
+ typeof style.height === 'number'
618
+ ? 'position'
619
+ : 'props';
608
620
  // A child of a `<glarea>` that only moved (issue #644): its pixels are
609
621
  // on a pane, one shift away, and the layout pass reports the move to
610
622
  // the overlay rather than claiming it (src/glnodes.js). A claim of the
@@ -613,12 +625,12 @@ export class Node {
613
625
  // whenever the overlay cannot move the pixels.
614
626
  if (
615
627
  this.parent?.isGlArea &&
616
- onlyInsetsChanged(style, prevStyle) &&
628
+ moved &&
617
629
  !this.paintChanged(newProps, prev)
618
630
  ) {
619
- this.invalidate(true, NO_DAMAGE, 'props');
631
+ this.invalidate(true, NO_DAMAGE, reason);
620
632
  } else {
621
- this._invalidateLayout('props');
633
+ this._invalidateLayout(reason);
622
634
  }
623
635
  } else {
624
636
  // The style half is asked here rather than inside `paintChanged`, and
@@ -970,12 +970,22 @@ export class WindowScrollBlit {
970
970
  if (ancestors.has(child)) {
971
971
  // on the path down: its solid background under the viewport is
972
972
  // translation-invariant, its border ring and corners are not.
973
- // The widest side is conservative for a non-uniform border
973
+ // The widest side is conservative for a non-uniform border.
974
+ //
975
+ // Rounded, because that is where the ring ends for what is
976
+ // inside it: at a fractional scale a 1px border is 1.25 or 1.5
977
+ // device pixels, and layout puts the children at the rounded
978
+ // inner edge — so the column the ring reaches a quarter of the
979
+ // way into is a child's, painted over whole. Tested exactly, that
980
+ // column refused every blit under a bordered box at 125%, the
981
+ // scale most Windows laptops run at.
974
982
  const bw = resolveBorderWidths(
975
983
  child.style ?? EMPTY_STYLE,
976
984
  child.direction,
977
985
  );
978
- const ring = Math.max(bw.top, bw.right, bw.bottom, bw.left);
986
+ const ring = Math.round(
987
+ Math.max(bw.top, bw.right, bw.bottom, bw.left),
988
+ );
979
989
  if (ring > 0 && !rectContains(insetRect(child.abs, ring), vp)) {
980
990
  return false;
981
991
  }
@@ -326,9 +326,26 @@ export class WindowFlush {
326
326
  // list was still taken (its bookkeeping is what keeps the two paths one
327
327
  // code) and is simply not consumed; the presenter diffs at the layer.
328
328
  if (typeof this.window.presentFrame === 'function') {
329
- // the panes are no presenter's: they paint from their damage either way
330
- if (this._overlaid.size !== 0) this._paintOverlays(paneDamage);
331
- this.window.presentFrame(this, damage);
329
+ // A presenter whose frame is the ordinary paint walk — a pass per
330
+ // damage rect into a context, the Windows window's — paints through
331
+ // the paint cache as an ntk window does, and says so
332
+ // (`usesPaintCache`). Without it every `<canvas cacheKey>` drew
333
+ // afresh in every pass that reached it, and a shadow, which is only
334
+ // ever baked into the cache (issue #413), was not drawn at all. The
335
+ // retained layer presenter paints nodes onto layers of their own and
336
+ // keeps to itself.
337
+ const cache = this.window.usesPaintCache
338
+ ? (this._paintCache ??= paintCacheFor(this.app))
339
+ : null;
340
+ cache?.beginFrame();
341
+ try {
342
+ // the panes are no presenter's: they paint from their damage either
343
+ // way
344
+ if (this._overlaid.size !== 0) this._paintOverlays(paneDamage);
345
+ this.window.presentFrame(this, damage);
346
+ } finally {
347
+ cache?.endFrame();
348
+ }
332
349
  this.app._reactX11Startup?.painted();
333
350
  return true;
334
351
  }
package/src/win32/a11y.js CHANGED
@@ -36,6 +36,22 @@ import { onApp } from '../trace-registry.js';
36
36
 
37
37
  const TRACE = process.env.REACT_X11_TRACE_A11Y === '1';
38
38
 
39
+ /**
40
+ * How often commits may push the mirror, in ms. A commit that follows a
41
+ * quiet spell pushes at once; a stream of them — a pan, a drag, a value
42
+ * ticking — pushes once per interval, the last of them catching up.
43
+ *
44
+ * UIA's clients are almost always listening on a desktop Windows (the
45
+ * touch keyboard, the text services and others ask for the tree), so the
46
+ * push is not the rare case the listening gate makes it elsewhere: a walk
47
+ * of the window and a marshalled diff, on every commit. For a graph pane
48
+ * whose every item moves on every step of a pan that was 1.6 ms of each
49
+ * frame. A screen reader reads positions on demand and does not need
50
+ * sixty of them a second; a focus change, which it is waiting on, still
51
+ * pushes at once (`hooks.focus`), as does an announcement.
52
+ */
53
+ const COMMIT_PUSH_MS = 100;
54
+
39
55
  // --------------------------------------------------------------------------
40
56
  // The one translation that is Windows' own
41
57
  // --------------------------------------------------------------------------
@@ -300,6 +316,9 @@ export class Win32Accessibility {
300
316
  this._pushed = new Set();
301
317
  this._unsubscribes = [];
302
318
  this.dead = false;
319
+ /** When a commit last pushed, and the push a stream of commits owes. */
320
+ this._lastCommitPush = -Infinity;
321
+ this._trailing = null;
303
322
  }
304
323
 
305
324
  _idOf(node) {
@@ -388,15 +407,29 @@ export class Win32Accessibility {
388
407
  * commit and paying nothing — the same shape as the AT-SPI bridge's "no
389
408
  * bus, no work".
390
409
  *
410
+ * *Nobody* is asked per window where the bridge can say (`uiaActive`):
411
+ * whether a client has read this window's tree in the last few seconds.
412
+ * UIA's own answer, `UiaClientsAreListening`, is whether any client on
413
+ * the desktop is subscribed to anything, and on a Windows running the
414
+ * touch keyboard or the text services that is always yes — so every
415
+ * commit walked and diffed the window for a mirror nobody read. A client
416
+ * that comes back after a quiet spell is noticed on its first read and
417
+ * asks for a fresh push (`wanted`). `global` keeps the desktop-wide answer
418
+ * for a focus change and an announcement: a client subscribed to focus
419
+ * events reads the focused element the moment it hears of it, before
420
+ * any read of ours could say it was there.
421
+ *
391
422
  * With one exception, which is what makes the gate safe: **every window is
392
423
  * pushed once regardless.** A client's first `WM_GETOBJECT` has to find a
393
424
  * tree, and it arrives before anything in this process knows a client
394
425
  * exists. After that first push the mirror is live, and a client attaching
395
426
  * later asks for a fresh one (`uia-wanted`).
396
427
  */
397
- flush({ force = false } = {}) {
428
+ flush({ force = false, global = false } = {}) {
398
429
  if (this.dead || this._dirty.size === 0) return;
399
- const listening = force || this._native.uiaListening();
430
+ const perWindow =
431
+ !force && !global && typeof this._native.uiaActive === 'function';
432
+ const listening = force || (!perWindow && this._native.uiaListening());
400
433
  const windows = [...this._dirty];
401
434
  this._dirty.clear();
402
435
  for (const win of windows) {
@@ -409,12 +442,37 @@ export class Win32Accessibility {
409
442
  this._dirty.add(win);
410
443
  continue;
411
444
  }
412
- if (!listening && this._pushed.has(wnd.id)) continue;
445
+ if (
446
+ !listening &&
447
+ this._pushed.has(wnd.id) &&
448
+ !(perWindow && this._native.uiaActive(wnd.id))
449
+ ) {
450
+ continue;
451
+ }
413
452
  this._pushed.add(wnd.id);
414
453
  this._push(win);
415
454
  }
416
455
  }
417
456
 
457
+ /** A commit's push, at most once per `COMMIT_PUSH_MS`. */
458
+ _commitFlush() {
459
+ if (this.dead || this._dirty.size === 0) return;
460
+ const now = performance.now();
461
+ const wait = this._lastCommitPush + COMMIT_PUSH_MS - now;
462
+ if (wait <= 0) {
463
+ this._lastCommitPush = now;
464
+ this.flush();
465
+ return;
466
+ }
467
+ if (this._trailing) return;
468
+ this._trailing = setTimeout(() => {
469
+ this._trailing = null;
470
+ this._lastCommitPush = performance.now();
471
+ this.flush();
472
+ }, wait);
473
+ this._trailing.unref?.();
474
+ }
475
+
418
476
  /** A window's HWND exists now, so what is owed for it can be pushed. */
419
477
  windowReady(wnd) {
420
478
  const win = this.toplevels.find((w) => w.window === wnd);
@@ -546,12 +604,12 @@ export class Win32Accessibility {
546
604
  // Pushed now rather than at the next commit: a focus change is what a
547
605
  // screen reader is waiting for, and the event has to follow an update
548
606
  // that already carries the new state or the client reads the old one.
549
- this.flush();
607
+ this.flush({ global: true });
550
608
  const wnd = this._windowOf(win);
551
609
  if (wnd && next) this._native.uiaFocusChanged(wnd.id, this._idOf(next));
552
610
  };
553
611
  hooks.windowFocus = (win) => this._dirty.add(win);
554
- hooks.commit = () => this.flush();
612
+ hooks.commit = () => this._commitFlush();
555
613
  hooks.announce = (text, opts) => {
556
614
  const win =
557
615
  this.toplevels.find((w) => w.events?.windowFocused) ??
@@ -562,7 +620,7 @@ export class Win32Accessibility {
562
620
  // The window must be in the mirror before anything can be announced
563
621
  // from it, which on the first announcement of a session it may not be.
564
622
  this._dirty.add(win);
565
- this.flush();
623
+ this.flush({ global: true });
566
624
  return Boolean(
567
625
  this._native.uiaAnnounce(wnd.id, String(text), !opts?.assertive),
568
626
  );
@@ -579,6 +637,8 @@ export class Win32Accessibility {
579
637
  bury() {
580
638
  if (this.dead) return;
581
639
  this.dead = true;
640
+ if (this._trailing) clearTimeout(this._trailing);
641
+ this._trailing = null;
582
642
  for (const key of Object.keys(hooks)) hooks[key] = null;
583
643
  for (const unsubscribe of this._unsubscribes) unsubscribe();
584
644
  this._unsubscribes = [];
package/src/win32/app.js CHANGED
@@ -16,6 +16,8 @@ import { setScreensForTests } from '../screens.js';
16
16
  import { createBezels } from './bezels.js';
17
17
  import { Win32InputMethod } from './ime.js';
18
18
  import { decodeKey, modifierMask } from './keymap.js';
19
+ import { refuseOverlay } from '../gloverlay.js';
20
+ import { Win32OverlayPane } from './overlay.js';
19
21
  import { Win32PaneHost } from './panehost.js';
20
22
  import { Win32PaneWindow } from './panewindow.js';
21
23
  import { Win32Surface } from './surface.js';
@@ -80,6 +82,8 @@ export class Win32App {
80
82
  // an in-app drop reads to keep `e.items` by reference (src/dnd.js).
81
83
  this._activeDrag = null;
82
84
  this._rafQueue = [];
85
+ /** Windows holding input for the next frame (`Win32Window._queueInput`). */
86
+ this._inputHeld = new Set();
83
87
  this._frameTimer = null;
84
88
  /** A frame has been asked for and not yet delivered — one outstanding
85
89
  * request at a time, whether the clock or the timer is answering it. */
@@ -118,6 +122,7 @@ export class Win32App {
118
122
  this.nativeBezels = null;
119
123
 
120
124
  this.fonts = new Win32FontManager(native);
125
+ this._installOverlay();
121
126
 
122
127
  const screens = native.listScreens?.() ?? [];
123
128
  const primary = screens[0] ?? {
@@ -275,10 +280,19 @@ export class Win32App {
275
280
  * A copy of the set is walked because `onDismiss` is what unmounts the
276
281
  * popup, and unmounting runs `ungrabPointer` — which deletes from the set
277
282
  * this loop would otherwise still be reading.
283
+ *
284
+ * `open` is the popups to judge — for a press, the ones that were open
285
+ * *before* it (`_route`). The press is delivered first, and a press that
286
+ * opens a menu — a `<Select>`, a menu button — opens it and takes its
287
+ * grab synchronously, in the same dispatch; judged afterwards, the new
288
+ * menu heard the press that opened it as a press outside itself and
289
+ * closed at once. Every `<Select>` did that. An X11 grab taken during a
290
+ * press does not see that press either.
278
291
  */
279
- _dismissOutsidePopups(pressed) {
292
+ _dismissOutsidePopups(pressed, open = null) {
280
293
  if (this._dismissOnOutside.size === 0) return;
281
- for (const wnd of [...this._dismissOnOutside]) {
294
+ for (const wnd of open ?? [...this._dismissOnOutside]) {
295
+ if (!this._dismissOnOutside.has(wnd)) continue;
282
296
  if (wnd === pressed) continue;
283
297
  if (wnd.destroyed) {
284
298
  this._dismissOnOutside.delete(wnd);
@@ -367,6 +381,29 @@ export class Win32App {
367
381
  return new Win32PaneHost(this, wnd);
368
382
  }
369
383
 
384
+ /**
385
+ * The pane a `<glarea>`'s children are drawn on (src/gloverlay.js): a
386
+ * composited layer above the window's swap chains, where the children's
387
+ * transparent pixels show the GL frame (src/win32/overlay.js). Having this
388
+ * at all is how the overlay knows it composites — and the X11 fallback it
389
+ * would otherwise reach for, a plain child window, is a GL surface here.
390
+ * Installed in the constructor where the bridge has layers.
391
+ */
392
+ _installOverlay() {
393
+ if (typeof this._native.layerCreate === 'function') {
394
+ this.createOverlayPane = (attributes) =>
395
+ new Win32OverlayPane(this, attributes);
396
+ return;
397
+ }
398
+ refuseOverlay(
399
+ this,
400
+ "this @windowkit/win32 predates layers, which are what a <glarea>'s " +
401
+ 'children are drawn on here, and a child window on this backend is ' +
402
+ 'a GL surface of its own. Update the bridge, or draw the overlay ' +
403
+ 'beside the surface',
404
+ );
405
+ }
406
+
370
407
  /**
371
408
  * The pane process's end of the frame channel: geometry and input in,
372
409
  * presents out. Feature-detected by the pane bootstrap
@@ -421,7 +458,18 @@ export class Win32App {
421
458
 
422
459
  _requestFrame(cb, wnd = null) {
423
460
  this._rafQueue.push({ cb, wnd });
424
- if (this._framePending || this._closed) return this._rafQueue.length;
461
+ this._armFrame();
462
+ return this._rafQueue.length;
463
+ }
464
+
465
+ /** A window is holding input for the next frame: there has to be one. */
466
+ _holdInput(wnd) {
467
+ this._inputHeld.add(wnd);
468
+ this._armFrame();
469
+ }
470
+
471
+ _armFrame() {
472
+ if (this._framePending || this._closed) return;
425
473
  this._framePending = true;
426
474
  // One tick, from the compositor if this Windows has a clock to wait on.
427
475
  // It answers false when it has not, and then — and only then — a timer.
@@ -434,7 +482,7 @@ export class Win32App {
434
482
  this._tickFrames();
435
483
  }, CLOCK_WATCHDOG_MS);
436
484
  this._frameTimer.unref?.();
437
- return this._rafQueue.length;
485
+ return;
438
486
  }
439
487
  this._frameTimer = setTimeout(() => {
440
488
  this._frameTimer = null;
@@ -442,10 +490,24 @@ export class Win32App {
442
490
  this._tickFrames();
443
491
  }, FRAME_INTERVAL_MS);
444
492
  this._frameTimer.unref?.();
445
- return this._rafQueue.length;
446
493
  }
447
494
 
448
495
  _tickFrames() {
496
+ // The input held for this frame goes first: what it changes is what the
497
+ // frame paints, and the frames its handlers ask for are this one —
498
+ // the callbacks are taken after it.
499
+ if (this._inputHeld.size !== 0) {
500
+ const held = [...this._inputHeld];
501
+ this._inputHeld.clear();
502
+ for (const wnd of held) {
503
+ if (wnd.destroyed) continue;
504
+ try {
505
+ wnd._flushInput();
506
+ } catch (err) {
507
+ if (process.env.NODE_ENV !== 'production') console.error(err);
508
+ }
509
+ }
510
+ }
449
511
  const due = this._rafQueue;
450
512
  this._rafQueue = [];
451
513
  const now = performance.now();
@@ -515,6 +577,11 @@ export class Win32App {
515
577
 
516
578
  const wnd = this._windows.get(event.id);
517
579
  if (!wnd) return;
580
+ // Whatever this is happened after the motion and the wheel the window is
581
+ // holding for its next frame, so they go first (`_queueInput`).
582
+ if (event.type !== 'mousemove' && event.type !== 'wheel') {
583
+ wnd._flushInput?.();
584
+ }
518
585
  switch (event.type) {
519
586
  case 'window-ready':
520
587
  wnd._onReady(event.a, event.b);
@@ -533,7 +600,7 @@ export class Win32App {
533
600
  break;
534
601
  }
535
602
  case 'mousemove':
536
- wnd.emit('mousemove', {
603
+ wnd._queueInput('mousemove', {
537
604
  x: event.a,
538
605
  y: event.b,
539
606
  ...rootOf(wnd, event),
@@ -545,7 +612,7 @@ export class Win32App {
545
612
  case 'wheel': {
546
613
  const deltaX = event.c;
547
614
  const deltaY = event.d;
548
- wnd.emit('wheel', {
615
+ wnd._queueInput('wheel', {
549
616
  name: 'wheel',
550
617
  x: event.a,
551
618
  y: event.b,
@@ -564,7 +631,13 @@ export class Win32App {
564
631
  break;
565
632
  }
566
633
  case 'mousedown':
567
- case 'mouseup':
634
+ case 'mouseup': {
635
+ // the menus this press may close: those open before it (see
636
+ // `_dismissOutsidePopups`)
637
+ const open =
638
+ event.type === 'mousedown' && this._dismissOnOutside.size !== 0
639
+ ? [...this._dismissOnOutside]
640
+ : null;
568
641
  // `keycode` is the button, numbered as X numbers them — the bridge
569
642
  // already speaks that vocabulary. It used to be hardcoded to 1, so
570
643
  // a right-click arrived as a left-click and no context menu ever
@@ -579,8 +652,9 @@ export class Win32App {
579
652
  // A press in one window is a press *outside* every open menu but the
580
653
  // one it landed in — which on X11 the grab would have delivered to
581
654
  // the menu instead of to this window.
582
- if (event.type === 'mousedown') this._dismissOutsidePopups(wnd);
655
+ if (open) this._dismissOutsidePopups(wnd, open);
583
656
  break;
657
+ }
584
658
  case 'keydown':
585
659
  case 'keyup': {
586
660
  // The bridge asked the active layout what the key types before the
@@ -187,13 +187,25 @@ class Win32Face {
187
187
  this.italic = style === 'italic' || style === 'oblique';
188
188
  }
189
189
 
190
+ /**
191
+ * Kept per size: a face's metrics never change, and the bridge answers
192
+ * each call by resolving the family through DirectWrite again. A trimmed
193
+ * `<text>` asks on every placement of its layout (`_trim`), which on a
194
+ * graph of widget cards was every body on every step of a drag.
195
+ */
190
196
  metrics(size) {
191
- return this._manager._native.fontMetrics(
192
- this.family,
193
- size,
194
- this.weight,
195
- this.italic,
196
- );
197
+ this._metrics ??= new Map();
198
+ let m = this._metrics.get(size);
199
+ if (m === undefined) {
200
+ m = this._manager._native.fontMetrics(
201
+ this.family,
202
+ size,
203
+ this.weight,
204
+ this.italic,
205
+ );
206
+ this._metrics.set(size, m);
207
+ }
208
+ return m;
197
209
  }
198
210
 
199
211
  /**
@@ -474,7 +486,14 @@ export class Win32FontManager {
474
486
  }
475
487
 
476
488
  match(family, options = {}) {
477
- const name = familyOf(this._native, family);
489
+ // the stack resolved once: a named family is a DirectWrite lookup, and
490
+ // every text layout asks
491
+ this._families ??= new Map();
492
+ let name = this._families.get(family);
493
+ if (name === undefined) {
494
+ name = familyOf(this._native, family);
495
+ this._families.set(family, name);
496
+ }
478
497
  const key = `${name}|${weightOf(options.weight)}|${options.style ?? 'normal'}`;
479
498
  let face = this._faces.get(key);
480
499
  if (!face) {
@@ -0,0 +1,244 @@
1
+ // The pane a `<glarea>`'s children are drawn on, on the Windows backend: a
2
+ // DirectComposition visual with a premultiplied surface of its own, above
3
+ // every swap chain in the window's tree (src/gloverlay.js; the bridge's
4
+ // src/layer.cc).
5
+ //
6
+ // Without it a pane was whatever `app.createWindow({ parent })` answers,
7
+ // which here is a GL surface (src/win32/glarea.js) — a WGL context and a
8
+ // swap chain per pane, and no 2D context at all. The children were laid out,
9
+ // claimed and painted nowhere, and a component that drew their cards under
10
+ // them had nothing left on screen where they were.
11
+ //
12
+ // Composited, like the Cocoa pane (src/cocoa/overlay.js): what the children
13
+ // leave transparent shows the GL frame, and a translucent fill, an
14
+ // antialiased edge or a shadow blends with it. Text on it is grayscale
15
+ // rather than ClearType, which Direct2D does on any surface with alpha
16
+ // (docs/windows.md §Text).
17
+ //
18
+ // Drawn like the window it lives in (src/win32/window.js): a DirectComposition
19
+ // surface hands out a context per `BeginDraw`, valid until `EndDraw`, so the
20
+ // overlay's passes come through `paintPasses` — one `BeginDraw` each — rather
21
+ // than through a context that is always there. `present` asks for the commit
22
+ // the window's own frame makes, so a pane and the window land together.
23
+ import { BackendContext2D } from '../backend/context2d.js';
24
+
25
+ export class Win32OverlayPane {
26
+ constructor(app, options) {
27
+ this.app = app;
28
+ this.parent = options.parent;
29
+ this._native = app._native;
30
+ this.destroyed = false;
31
+ this.rect = {
32
+ x: options.x ?? 0,
33
+ y: options.y ?? 0,
34
+ width: options.width ?? 1,
35
+ height: options.height ?? 1,
36
+ };
37
+ // the bridge's layer, made once the window has a composition tree to
38
+ // hang it in — the tree renders before the window is ready
39
+ this._layer = 0;
40
+ this._visible = true;
41
+ // non-zero only between a pass's BeginDraw and its EndDraw
42
+ this._surface = 0;
43
+ this._gen = 0;
44
+ this._ctx = null;
45
+ this._handlers = {};
46
+ this._ensureLayer();
47
+ }
48
+
49
+ get width() {
50
+ return this.rect.width;
51
+ }
52
+
53
+ get height() {
54
+ return this.rect.height;
55
+ }
56
+
57
+ on(name, fn) {
58
+ (this._handlers[name] ??= []).push(fn);
59
+ }
60
+
61
+ emit(name, ev) {
62
+ for (const fn of this._handlers[name] ?? []) fn(ev);
63
+ }
64
+
65
+ _ensureLayer() {
66
+ if (this._layer || this.destroyed) return this._layer;
67
+ if (!this.parent?._composed || this.parent.destroyed) return 0;
68
+ const { x, y, width, height } = this.rect;
69
+ this._layer = this._native.layerCreate(
70
+ this.parent.id,
71
+ Math.round(x),
72
+ Math.round(y),
73
+ Math.max(1, Math.round(width)),
74
+ Math.max(1, Math.round(height)),
75
+ );
76
+ if (this._layer && !this._visible) {
77
+ this._native.layerSetVisible(this._layer, false);
78
+ }
79
+ return this._layer;
80
+ }
81
+
82
+ /** Geometry in device px, the unit the overlay's rects are in. */
83
+ setState(rect) {
84
+ if (this.destroyed) return;
85
+ this.rect = rect;
86
+ if (!this._layer) return;
87
+ this._native.layerSetRect(
88
+ this._layer,
89
+ Math.round(rect.x),
90
+ Math.round(rect.y),
91
+ Math.max(1, Math.round(rect.width)),
92
+ Math.max(1, Math.round(rect.height)),
93
+ );
94
+ this._owe();
95
+ }
96
+
97
+ map() {
98
+ this._setVisible(true);
99
+ }
100
+
101
+ unmap() {
102
+ this._setVisible(false);
103
+ }
104
+
105
+ _setVisible(on) {
106
+ if (this._visible === on) return;
107
+ this._visible = on;
108
+ if (!this._layer || this.destroyed) return;
109
+ this._native.layerSetVisible(this._layer, on);
110
+ this._owe();
111
+ }
112
+
113
+ getContext() {
114
+ if (!this._ctx) {
115
+ this._ctx = new BackendContext2D(
116
+ this._native,
117
+ () => this._surface,
118
+ () => this._gen,
119
+ );
120
+ this._ctx._fonts = this.app.fonts;
121
+ }
122
+ return this._ctx;
123
+ }
124
+
125
+ /**
126
+ * The overlay's passes, each its own `BeginDraw` — `rect`s in window
127
+ * coordinates, each painted by `paint(ctx, rect)` with the context drawing
128
+ * in window coordinates too. A pass is grown to whole pixels first:
129
+ * everything inside a `BeginDraw` rect is undefined until it is painted,
130
+ * so the rect the painter clears and the rect the surface hands out have
131
+ * to be the same one.
132
+ */
133
+ paintPasses(passes, paint) {
134
+ if (this.destroyed) return;
135
+ if (!this._ensureLayer()) {
136
+ // Nothing to draw into yet. The overlay believes these passes landed,
137
+ // so it is told they did not: all of the pane is owed, on the next
138
+ // frame, when the window will have a tree to hang the layer in.
139
+ setImmediate(() => {
140
+ if (!this.destroyed) this.emit('draw', {});
141
+ });
142
+ return;
143
+ }
144
+ const ctx = this.getContext();
145
+ const ox = Math.round(this.rect.x);
146
+ const oy = Math.round(this.rect.y);
147
+ let drawn = false;
148
+ for (const pass of passes) {
149
+ const x0 = Math.max(Math.floor(pass.x), ox);
150
+ const y0 = Math.max(Math.floor(pass.y), oy);
151
+ const x1 = Math.min(
152
+ Math.ceil(pass.x + pass.width),
153
+ ox + Math.max(1, Math.round(this.rect.width)),
154
+ );
155
+ const y1 = Math.min(
156
+ Math.ceil(pass.y + pass.height),
157
+ oy + Math.max(1, Math.round(this.rect.height)),
158
+ );
159
+ if (x1 <= x0 || y1 <= y0) continue;
160
+ const handle = this._native.layerBeginDraw(
161
+ this._layer,
162
+ x0 - ox,
163
+ y0 - oy,
164
+ x1 - x0,
165
+ y1 - y0,
166
+ );
167
+ if (!handle) continue;
168
+ this._surface = handle;
169
+ this._gen++;
170
+ try {
171
+ // the surface draws in the layer's own coordinates; the painter
172
+ // draws in the window's
173
+ ctx.save();
174
+ try {
175
+ ctx.translate(-ox, -oy);
176
+ paint(ctx, { x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
177
+ } finally {
178
+ ctx.restore();
179
+ }
180
+ drawn = true;
181
+ } finally {
182
+ this._surface = 0;
183
+ this._native.layerEndDraw(this._layer);
184
+ }
185
+ }
186
+ if (drawn) this._owe();
187
+ }
188
+
189
+ /**
190
+ * Move the pixels of `rect` (pane coordinates, device px) by (dx, dy) —
191
+ * ntk `Window.scrollRegion`'s contract: the band that survives the shift
192
+ * inside `rect` moves, the rest of it is left as it was, and false means
193
+ * nothing moved, so the caller repaints instead (issue #644).
194
+ */
195
+ scrollRegion(rect, dx, dy) {
196
+ if (this.destroyed || !this._layer) return false;
197
+ if (!Number.isInteger(dx) || !Number.isInteger(dy)) return false;
198
+ const moved = this._native.layerScroll(
199
+ this._layer,
200
+ Math.round(rect.x),
201
+ Math.round(rect.y),
202
+ Math.round(rect.width),
203
+ Math.round(rect.height),
204
+ dx,
205
+ dy,
206
+ );
207
+ if (moved) this._owe();
208
+ return Boolean(moved);
209
+ }
210
+
211
+ /** What was painted or moved, onto the screen with the window's frame. */
212
+ present() {
213
+ this._owe();
214
+ }
215
+
216
+ /**
217
+ * A commit is owed. The window's frame makes one after the overlay has
218
+ * painted (`presentFrame`, src/win32/window.js), so a pane's pixels and
219
+ * the window's land together; a change made outside a frame — a pane
220
+ * hidden between two — is committed on a microtask instead, once.
221
+ */
222
+ _owe() {
223
+ const wnd = this.parent;
224
+ if (!wnd || wnd.destroyed) return;
225
+ if (wnd._owesCommit) return;
226
+ wnd._owesCommit = true;
227
+ queueMicrotask(() => {
228
+ if (!wnd._owesCommit) return;
229
+ wnd._owesCommit = false;
230
+ if (!wnd.destroyed) this._native.commit();
231
+ });
232
+ }
233
+
234
+ destroy() {
235
+ if (this.destroyed) return;
236
+ this.destroyed = true;
237
+ this._ctx = null;
238
+ if (this._layer) {
239
+ this._native.layerDestroy(this._layer);
240
+ this._layer = 0;
241
+ this._owe();
242
+ }
243
+ }
244
+ }
@@ -287,6 +287,12 @@ export class Win32PaneWindow {
287
287
  }
288
288
  }
289
289
 
290
+ /** The frame is the ordinary paint walk, so it paints through core's
291
+ * paint cache (src/nodes/window/flush.js), as the window does. */
292
+ get usesPaintCache() {
293
+ return true;
294
+ }
295
+
290
296
  /**
291
297
  * No scroll blit. The fast path moves a band of pixels *within the surface
292
298
  * being drawn into*, and this one is `STALE_FRAMES` frames behind — the
@@ -53,6 +53,11 @@ export class Win32Window {
53
53
  this._pendingFrames = [];
54
54
  this._owesFullPaint = false;
55
55
  this._dirty = false;
56
+ // a pane over a `<glarea>` here changed and its commit is this frame's
57
+ // (src/win32/overlay.js)
58
+ this._owesCommit = false;
59
+ // pointer motion and wheel held for the next frame (`_queueInput`)
60
+ this._pendingInput = null;
56
61
 
57
62
  // `overrideRedirect` is how a `<popup>` says what it is — a menu, a
58
63
  // select's list, a tooltip. On X11 it means "the window manager does not
@@ -104,6 +109,58 @@ export class Win32Window {
104
109
  for (const fn of this._handlers[name] ?? []) fn(ev);
105
110
  }
106
111
 
112
+ /**
113
+ * Hold a pointer motion or a wheel for the next frame, merged with any
114
+ * already held: ntk's coalescing on X11 (its lib/window.js,
115
+ * `_deliverEvent`), which the rest of the event path was written against
116
+ * — src/events.js paces motion on the strength of it.
117
+ *
118
+ * Windows merges WM_MOUSEMOVE itself, but only while a queue waits to be
119
+ * read, and the bridge reads its messages on a UI thread of its own and
120
+ * posts every one of them to the loop. So a mouse reporting a thousand
121
+ * times a second was a thousand events on the loop, and a handler slower
122
+ * than a millisecond fell behind the pointer — then replayed every
123
+ * position it had missed, a frame of work each, long after the pointer
124
+ * had stopped: a box selection over a large graph trailed the pointer by
125
+ * seconds. Held here, a burst is one event a frame: the newest motion,
126
+ * with the rest riding along in `ev.coalesced`, and the wheel's deltas
127
+ * added up at its last position.
128
+ */
129
+ _queueInput(name, ev) {
130
+ const pending = (this._pendingInput ??= new Map());
131
+ const prev = pending.get(name);
132
+ if (!prev) {
133
+ ev.coalesced = [ev];
134
+ pending.set(name, ev);
135
+ } else if (name === 'wheel') {
136
+ // scroll distance adds up — the last delta alone would throw away all
137
+ // but the final step of a fast scroll
138
+ prev.coalesced.push(ev);
139
+ prev.deltaX += ev.deltaX;
140
+ prev.deltaY += ev.deltaY;
141
+ prev.x = ev.x;
142
+ prev.y = ev.y;
143
+ prev.rootx = ev.rootx;
144
+ prev.rooty = ev.rooty;
145
+ prev.smooth = prev.smooth || ev.smooth;
146
+ } else {
147
+ ev.coalesced = prev.coalesced;
148
+ ev.coalesced.push(ev);
149
+ pending.set(name, ev);
150
+ }
151
+ this.app._holdInput(this);
152
+ }
153
+
154
+ /** Deliver what `_queueInput` held: at the top of the next frame, and
155
+ * before any other event of this window's, so a handler sees events in
156
+ * the order they happened — a drag its last move, then the release. */
157
+ _flushInput() {
158
+ const pending = this._pendingInput;
159
+ if (pending === null) return;
160
+ this._pendingInput = null;
161
+ for (const [name, ev] of pending) this.emit(name, ev);
162
+ }
163
+
107
164
  /**
108
165
  * The bridge says the HWND exists. Composition is set up here and not at
109
166
  * construction because there is no HWND to target until now — the command
@@ -168,6 +225,7 @@ export class Win32Window {
168
225
  destroy() {
169
226
  if (this.destroyed) return;
170
227
  this.destroyed = true;
228
+ this._pendingInput = null;
171
229
  this._native.destroyWindow?.(this.id);
172
230
  this.app._unregister(this);
173
231
  }
@@ -577,7 +635,13 @@ export class Win32Window {
577
635
  this._native.endDraw(this.id);
578
636
  }
579
637
  }
580
- if (painted) this._native.commit();
638
+ // …and the panes over a `<glarea>` in this window, painted before this
639
+ // (src/win32/overlay.js), in the same commit: a frame whose only change
640
+ // was theirs still owes one
641
+ if (painted || this._owesCommit) {
642
+ this._owesCommit = false;
643
+ this._native.commit();
644
+ }
581
645
  }
582
646
 
583
647
  /**
@@ -604,6 +668,12 @@ export class Win32Window {
604
668
  );
605
669
  }
606
670
 
671
+ /** The frame is the ordinary paint walk, a pass per damage rect, so it
672
+ * paints through core's paint cache (src/nodes/window/flush.js). */
673
+ get usesPaintCache() {
674
+ return true;
675
+ }
676
+
607
677
  /** DirectComposition does not hold a frame back the way an X server's fence
608
678
  * or a WindowServer's buffer does: Commit is asynchronous and the surface
609
679
  * retains its own pixels. So there is never a frame in flight to wait for. */