react-x11 2.22.6 → 2.22.7

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.22.6",
3
+ "version": "2.22.7",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -108,7 +108,7 @@
108
108
  "yoga-layout": "^3.2.1"
109
109
  },
110
110
  "optionalDependencies": {
111
- "@windowkit/appkit": "^0.13.0",
111
+ "@windowkit/appkit": "^0.14.0",
112
112
  "@windowkit/wayland": "^3.1.1",
113
113
  "@windowkit/win32": "^0.0.5",
114
114
  "dbus-native": "^0.15.1",
@@ -102,11 +102,53 @@ function parseColor(color) {
102
102
  }
103
103
 
104
104
  /** UTF-16 offset of each code point boundary, plus the end. */
105
+ /** Where each code point of `text` starts, in code units — or null when
106
+ * the two are the same index, which is all text with no surrogate pair in
107
+ * it. A table per paragraph was a walk of every character of a document,
108
+ * every time it was laid out. */
105
109
  function codeUnitOffsets(text) {
110
+ if (!SURROGATE.test(text)) return null;
106
111
  const offsets = [0];
107
112
  for (const ch of text) offsets.push(offsets[offsets.length - 1] + ch.length);
108
113
  return offsets;
109
114
  }
115
+ const SURROGATE = /[\uD800-\uDFFF]/;
116
+ const NO_GEOMETRY = new Float64Array(0);
117
+
118
+ /**
119
+ * A layout's lines from the bridge's packed geometry (`createLayout`'s
120
+ * `packed`): ten numbers a line, five a run, into the objects the
121
+ * unpacked form has, key for key. A bridge that answered neither form
122
+ * laid out no lines.
123
+ */
124
+ function unpackLines(lineData = NO_GEOMETRY, runData = NO_GEOMETRY) {
125
+ const lines = new Array(lineData.length / 10);
126
+ for (let i = 0, l = 0, r = 0; i < lineData.length; i += 10, l++) {
127
+ const runs = new Array(lineData[i + 9]);
128
+ for (let k = 0; k < runs.length; k++, r += 5) {
129
+ runs[k] = {
130
+ x: runData[r],
131
+ width: runData[r + 1],
132
+ start: runData[r + 2],
133
+ end: runData[r + 3],
134
+ rtl: runData[r + 4] === 1,
135
+ };
136
+ }
137
+ lines[l] = {
138
+ x: lineData[i],
139
+ y: lineData[i + 1],
140
+ width: lineData[i + 2],
141
+ height: lineData[i + 3],
142
+ baseline: lineData[i + 4],
143
+ ascent: lineData[i + 5],
144
+ descent: lineData[i + 6],
145
+ start: lineData[i + 7],
146
+ end: lineData[i + 8],
147
+ runs,
148
+ };
149
+ }
150
+ return lines;
151
+ }
110
152
 
111
153
  class CocoaTextLayout {
112
154
  constructor(native, raw, text) {
@@ -116,16 +158,20 @@ class CocoaTextLayout {
116
158
  this._cpToCu = codeUnitOffsets(text);
117
159
  this.width = raw.width;
118
160
  this.height = raw.height;
119
- this.lines = raw.lines;
161
+ this.lines = raw.lines ?? unpackLines(raw.lineData, raw.runData);
120
162
  }
121
163
 
122
164
  _cuOf(cp) {
123
165
  const t = this._cpToCu;
166
+ if (t === null) return Math.max(0, Math.min(cp, this._text.length));
124
167
  return t[Math.max(0, Math.min(cp, t.length - 1))];
125
168
  }
126
169
 
127
170
  _cpOf(cu) {
128
171
  const t = this._cpToCu;
172
+ if (t === null) {
173
+ return cu <= 0 ? 0 : Math.min(Math.ceil(cu), this._text.length);
174
+ }
129
175
  // t is sorted; layouts are short, a linear walk is fine
130
176
  for (let i = 0; i < t.length; i++) if (t[i] >= cu) return i;
131
177
  return t.length - 1;
@@ -500,6 +546,10 @@ function spacedFeatures(features, spacing) {
500
546
  : OPTIONAL_LIGATURES_OFF;
501
547
  }
502
548
 
549
+ /** The text the kept typesetters may hold, in UTF-16 units: a document of
550
+ * about a megabyte, at 25 bytes a unit in CoreText. */
551
+ const KEPT_TYPESETTER_UNITS = 1 << 20;
552
+
503
553
  export class CocoaFontManager {
504
554
  /** @param native the @windowkit/appkit module; the tests hand in a fake */
505
555
  constructor(native = loadNative()) {
@@ -513,6 +563,50 @@ export class CocoaFontManager {
513
563
  this._sized = new Map(); // face key|size|variations -> CTFont handle
514
564
  this._layouts = new Map(); // layout signature -> CocoaTextLayout (LRU)
515
565
  this._faceByPs = new Map(); // PostScript name -> face CoreText chose itself
566
+ // paragraph -> its typesetter, kept to lay it out at another width
567
+ // (`_keptTypesetter`); LRU, bounded by the UTF-16 units they hold. Only
568
+ // a bridge that keeps them (`@windowkit/appkit` 0.14) has the verb.
569
+ this._keeps = typeof native.releaseTypesetter === 'function';
570
+ this._typesetters = new Map();
571
+ this._typesetterUnits = 0;
572
+ }
573
+
574
+ /**
575
+ * A paragraph's typesetter, kept from a layout of the same text at any
576
+ * width. Two thirds of a layout is the text becoming glyphs — the
577
+ * attributed string, then CoreText's shaping — and a paragraph laid out
578
+ * at another width breaks the same glyphs into other lines: a window
579
+ * resize lays a document out at every width it passes through, and a
580
+ * paragraph's max-content and wrapped measurements are two layouts of
581
+ * one text. The cache holds `KEPT_TYPESETTER_UNITS` of text, about
582
+ * 25 MB, which is a long document; past that the paragraph laid out
583
+ * least recently is let go.
584
+ */
585
+ _keptTypesetter(key) {
586
+ const kept = this._typesetters.get(key);
587
+ if (kept === undefined) return null;
588
+ this._typesetters.delete(key);
589
+ this._typesetters.set(key, kept);
590
+ return kept.handle;
591
+ }
592
+
593
+ _keepTypesetter(key, handle, units) {
594
+ this._typesetters.set(key, { handle, units });
595
+ this._typesetterUnits += units;
596
+ while (this._typesetterUnits > KEPT_TYPESETTER_UNITS) {
597
+ const [oldest, entry] = this._typesetters.entries().next().value;
598
+ this._typesetters.delete(oldest);
599
+ this._typesetterUnits -= entry.units;
600
+ this._native.releaseTypesetter(entry.handle);
601
+ }
602
+ }
603
+
604
+ _forgetTypesetters() {
605
+ for (const { handle } of this._typesetters.values()) {
606
+ this._native.releaseTypesetter(handle);
607
+ }
608
+ this._typesetters.clear();
609
+ this._typesetterUnits = 0;
516
610
  }
517
611
 
518
612
  /** A face the app loaded joins every fallback chain: what the faces that
@@ -941,6 +1035,7 @@ export class CocoaFontManager {
941
1035
  this._faces.clear();
942
1036
  this._sized.clear();
943
1037
  this._layouts.clear();
1038
+ if (this._keeps) this._forgetTypesetters();
944
1039
  this._forgetCovers();
945
1040
  // `null` on purpose: src/fonts.js keeps the fontkit face it already
946
1041
  // opened as the handle, which is the one whose metrics apps can read;
@@ -957,8 +1052,10 @@ export class CocoaFontManager {
957
1052
  // Memoized: an immediate-mode caller (the fonts app's specimen canvas,
958
1053
  // a hover pass) lays the same paragraph out every repaint, and a
959
1054
  // CTFramesetter per pointer move is what sluggish feels like. The key
960
- // is everything shaping reads; ~64 entries covers a screenful.
961
- const signature = JSON.stringify([
1055
+ // is everything shaping reads — the paragraph, `shape`, which is also
1056
+ // what its kept typesetter is found by — and then everything breaking
1057
+ // it into lines reads; ~64 entries covers a screenful.
1058
+ const shape = JSON.stringify([
962
1059
  spans.map((sp) => [
963
1060
  sp.text,
964
1061
  sp.family ?? base.family,
@@ -971,13 +1068,17 @@ export class CocoaFontManager {
971
1068
  sp.features ?? base.features ?? null,
972
1069
  sp.letterSpacing ?? base.letterSpacing ?? 0,
973
1070
  ]),
974
- options.maxWidth,
975
- options.align,
976
- options.lineHeight,
977
- options.maxLines,
978
- options.overflow,
979
1071
  options.direction,
980
1072
  ]);
1073
+ const signature =
1074
+ shape +
1075
+ JSON.stringify([
1076
+ options.maxWidth,
1077
+ options.align,
1078
+ options.lineHeight,
1079
+ options.maxLines,
1080
+ options.overflow,
1081
+ ]);
981
1082
  const hit = this._layouts.get(signature);
982
1083
  if (hit) {
983
1084
  // refresh LRU position
@@ -1054,8 +1155,17 @@ export class CocoaFontManager {
1054
1155
  : minContent
1055
1156
  ? brokenAtEveryOpportunity(nativeSpans)
1056
1157
  : nativeSpans;
1158
+ // The paragraph's kept typesetter, where it has one. A min-content
1159
+ // measurement lays out other text — the paragraph broken at every
1160
+ // opportunity, and capped for an eliding one — and keeps its own.
1161
+ const shapeKey = probe
1162
+ ? `${shape}|${maxLines}`
1163
+ : minContent
1164
+ ? `${shape}|min`
1165
+ : shape;
1166
+ const typesetter = this._keeps ? this._keptTypesetter(shapeKey) : null;
1057
1167
  const raw = this._native.createLayout({
1058
- spans: laid,
1168
+ ...(typesetter ? { typesetter } : { spans: laid }),
1059
1169
  maxWidth:
1060
1170
  Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : undefined,
1061
1171
  align: flushFor(align, direction),
@@ -1063,7 +1173,15 @@ export class CocoaFontManager {
1063
1173
  maxLines: probe || !Number.isFinite(maxLines) ? undefined : maxLines,
1064
1174
  ellipsis: !probe && overflow === 'ellipsis',
1065
1175
  rtl: direction === 'rtl',
1176
+ // the geometry as two arrays rather than an object a line and a run:
1177
+ // a tenth of the calls across the bridge (`unpackLines`)
1178
+ ...(this._keeps ? { keep: typesetter === null, packed: true } : null),
1066
1179
  });
1180
+ if (raw.typesetter) {
1181
+ let units = 0;
1182
+ for (const span of laid) units += span.text.length;
1183
+ this._keepTypesetter(shapeKey, raw.typesetter, units);
1184
+ }
1067
1185
  // the text the native actually laid out: a min-content measurement's
1068
1186
  // line and run ranges are indices into the broken text, and the two have
1069
1187
  // to agree for `indexAt`/`caretPosition` to mean anything at all
@@ -50,6 +50,50 @@ function owe(owed, damage) {
50
50
  return rects.length > OWED_RECTS_MAX ? 'full' : rects;
51
51
  }
52
52
 
53
+ /** The band `scrollSurface` would write for a shift of `scroll` inside a
54
+ * `width` x `height` bitmap, clamped the way the native clamps the rect:
55
+ * rect ∩ (rect + delta), or null when nothing survives the shift. */
56
+ function survivingBand(scroll, width, height) {
57
+ const { x, y, dx, dy } = scroll;
58
+ if (!dx && !dy) return null;
59
+ const clamp = (v, hi) => (v < 0 ? 0 : v > hi ? hi : v);
60
+ const x0 = clamp(x, width);
61
+ const y0 = clamp(y, height);
62
+ const x1 = clamp(x + scroll.width, width);
63
+ const y1 = clamp(y + scroll.height, height);
64
+ const left = Math.max(x0, x0 + dx);
65
+ const top = Math.max(y0, y0 + dy);
66
+ const right = Math.min(x1, x1 + dx);
67
+ const bottom = Math.min(y1, y1 + dy);
68
+ if (right <= left || bottom <= top) return null;
69
+ return { x: left, y: top, width: right - left, height: bottom - top };
70
+ }
71
+
72
+ /** Flat `[x, y, w, h, …]` rects less the `[x, y, w, h]` hole: the rows above
73
+ * and below it full width, the pieces beside it its rows only. */
74
+ function aroundHole(rects, [hx, hy, hw, hh]) {
75
+ const out = [];
76
+ for (let i = 0; i + 3 < rects.length; i += 4) {
77
+ const x = rects[i];
78
+ const y = rects[i + 1];
79
+ const right = x + rects[i + 2];
80
+ const bottom = y + rects[i + 3];
81
+ const top = Math.max(y, hy);
82
+ const under = Math.min(bottom, hy + hh);
83
+ const left = Math.max(x, hx);
84
+ const beside = Math.min(right, hx + hw);
85
+ if (beside <= left || under <= top) {
86
+ out.push(x, y, right - x, bottom - y);
87
+ continue;
88
+ }
89
+ if (top > y) out.push(x, y, right - x, top - y);
90
+ if (bottom > under) out.push(x, under, right - x, bottom - under);
91
+ if (left > x) out.push(x, top, left - x, under - top);
92
+ if (right > beside) out.push(beside, top, right - beside, under - top);
93
+ }
94
+ return out;
95
+ }
96
+
53
97
  export class CocoaWindow {
54
98
  constructor(app, attributes = {}) {
55
99
  this.app = app;
@@ -599,8 +643,18 @@ export class CocoaWindow {
599
643
  * always drew into.
600
644
  *
601
645
  * Then what it missed, copied across from the frame on glass.
646
+ *
647
+ * When the frame's first draw is a scroll blit (`scrollRegion`), the band
648
+ * it moves comes across already moved: one copy from the frame on glass
649
+ * at the shift, where catching up and then shifting in place wrote the
650
+ * band twice — the catch-up owes it, since the frame before blitted too.
651
+ * The catch-up copies the rest. Every pixel ends as the two passes left
652
+ * it, the strips the shift exposes included, so what the frame repaints
653
+ * does not change. Answers whether the band moved, or undefined when
654
+ * there was no scroll to take or it has to move in place: a chain of
655
+ * one, a buffer that owes nothing, a bridge without `blitSurface`.
602
656
  */
603
- _takeBack() {
657
+ _takeBack(scroll = null) {
604
658
  const chain = this._chain;
605
659
  let free = null;
606
660
  let oldest = null;
@@ -639,10 +693,9 @@ export class CocoaWindow {
639
693
  // re-syncs its sticky state off the generation
640
694
  this._surfaceGen++;
641
695
  this._native.surfaceLock(back.handle);
696
+ let moved;
642
697
  if (back.owed && back !== chain.front) {
643
- this._native.copySurfaceRegion(
644
- chain.front.handle,
645
- back.handle,
698
+ let owed =
646
699
  back.owed === 'full'
647
700
  ? null
648
701
  : back.owed.flatMap((r) => [
@@ -650,10 +703,39 @@ export class CocoaWindow {
650
703
  Math.floor(r.y),
651
704
  Math.ceil(r.width) + 1,
652
705
  Math.ceil(r.height) + 1,
653
- ]),
654
- );
706
+ ]);
707
+ if (scroll && typeof this._native.blitSurface === 'function') {
708
+ const { width, height } = this._surfaceSize;
709
+ const band = survivingBand(scroll, width, height);
710
+ // the band and where it comes from are both inside the bitmap, so
711
+ // the copy is the whole band — anything else is a bridge that did
712
+ // not copy, and the band moves in place after the plain catch-up
713
+ const copied =
714
+ band &&
715
+ this._native.blitSurface(
716
+ chain.front.handle,
717
+ band.x - scroll.dx,
718
+ band.y - scroll.dy,
719
+ band.width,
720
+ band.height,
721
+ back.handle,
722
+ band.x,
723
+ band.y,
724
+ );
725
+ if (!band) {
726
+ moved = false;
727
+ } else if (Array.isArray(copied)) {
728
+ moved = true;
729
+ owed = aroundHole(owed ?? [0, 0, width, height], copied);
730
+ }
731
+ }
732
+ // (an empty list is the whole bitmap to the native, not nothing)
733
+ if (owed === null || owed.length > 0) {
734
+ this._native.copySurfaceRegion(chain.front.handle, back.handle, owed);
735
+ }
655
736
  }
656
737
  back.owed = null;
738
+ return moved;
657
739
  }
658
740
 
659
741
  /**
@@ -832,18 +914,30 @@ export class CocoaWindow {
832
914
  return false;
833
915
  }
834
916
  if (!Number.isInteger(dx) || !Number.isInteger(dy)) return false;
835
- // The band moves in the buffer this frame draws into — taken, and
836
- // caught up to the frame on glass, before anything moves in it.
837
- const surface = this._ensureSurface();
838
- const moved = this._native.scrollSurface(
839
- surface,
840
- Math.round(rect.x),
841
- Math.round(rect.y),
842
- Math.round(rect.width),
843
- Math.round(rect.height),
844
- dx,
845
- dy,
846
- );
917
+ const x = Math.round(rect.x);
918
+ const y = Math.round(rect.y);
919
+ const width = Math.round(rect.width);
920
+ const height = Math.round(rect.height);
921
+ // The band moves in the buffer this frame draws into. When this is the
922
+ // frame's first draw, taking that buffer moves it (`_takeBack`);
923
+ // otherwise, or where that cannot, it is taken and caught up to the
924
+ // frame on glass first, and the band moves in place.
925
+ const chain = this._chain;
926
+ let moved =
927
+ chain && !chain.back
928
+ ? this._takeBack({ x, y, width, height, dx, dy })
929
+ : undefined;
930
+ if (moved === undefined) {
931
+ moved = this._native.scrollSurface(
932
+ this._ensureSurface(),
933
+ x,
934
+ y,
935
+ width,
936
+ height,
937
+ dx,
938
+ dy,
939
+ );
940
+ }
847
941
  if (moved) {
848
942
  this._dirty = true;
849
943
  // The band moved inside the BACK buffer only. After the flip every
@@ -851,14 +945,7 @@ export class CocoaWindow {
851
945
  // is owed only covers what the flush painted — the strips the shift
852
946
  // exposed — so the next frame would blit a band one frame stale.
853
947
  // Record the shifted rect as painted, and the catch-up carries it.
854
- this.noteFrameDamage([
855
- {
856
- x: Math.round(rect.x),
857
- y: Math.round(rect.y),
858
- width: Math.round(rect.width),
859
- height: Math.round(rect.height),
860
- },
861
- ]);
948
+ this.noteFrameDamage([{ x, y, width, height }]);
862
949
  }
863
950
  return Boolean(moved);
864
951
  }