react-x11 2.41.0 → 2.41.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.41.0",
3
+ "version": "2.41.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -104,14 +104,14 @@
104
104
  "ical.js": "^2.2.1",
105
105
  "image-in-browser": "^3.6.0",
106
106
  "linebreak": "^1.1.0",
107
- "ntk": "^8.17.8",
107
+ "ntk": "^8.23.0",
108
108
  "react-reconciler": "^0.34.0",
109
109
  "yoga-layout": "^3.2.1"
110
110
  },
111
111
  "optionalDependencies": {
112
112
  "@windowkit/appkit": "^0.25.0",
113
113
  "@windowkit/wayland": "^3.1.1",
114
- "@windowkit/win32": "^0.0.7",
114
+ "@windowkit/win32": "^0.0.9",
115
115
  "dbus-native": "^0.16.0",
116
116
  "x11-dri": "^0.9.0"
117
117
  },
@@ -31,6 +31,12 @@ const ASKED_KEPT = 16;
31
31
  * drag itself. */
32
32
  const GESTURE_GAP_MS = 500;
33
33
 
34
+ /** How far inside the budget a frame of a pane that fell behind has to
35
+ * come for its frames to be anchored again (`_answered`): a pane whose
36
+ * frames come near the budget keeps one look rather than taking the
37
+ * other at every frame. */
38
+ const KEPT_UP = 0.75;
39
+
34
40
  /** One shared surface on glass: a sublayer of a window's root layer, its
35
41
  * contents the IOSurface the pane last presented into it. */
36
42
  class PaneLayer {
@@ -54,6 +60,10 @@ class PaneLayer {
54
60
  this._asked = [];
55
61
  this._askedAt = null;
56
62
  this._behind = false;
63
+ // when a frame last answered a size, and how many frames in a row came
64
+ // later than the budget (`_answered`)
65
+ this._answeredAt = -Infinity;
66
+ this._late = 0;
57
67
  }
58
68
 
59
69
  /**
@@ -73,13 +83,28 @@ class PaneLayer {
73
83
  * size may take before the old one is shown in its place: 0 stretches
74
84
  * always, as before. A bridge that predates `contentsGravity` ignores it,
75
85
  * and stretches.
86
+ *
87
+ * Behind is a pane whose frames come later than the budget, not one with
88
+ * a size waiting that long. `<Frame>` sends a pane the newest size once
89
+ * it has painted the last, so the sizes a drag passes through while a
90
+ * frame is being painted are never sent, and never answered: a size
91
+ * waits until a frame of a later one lands, two pane frames at most. A
92
+ * page that painted in 27 ms answered every size it was sent in 31, and
93
+ * still had one 52 ms old at every step of the drag — so its layer was
94
+ * stretched at every step and anchored again at every frame, the page
95
+ * scaling and coming back thirty times a second. Here a size waiting
96
+ * longer than the budget counts only when no frame has answered any
97
+ * size in that time, which is a pane that stopped, and a frame that
98
+ * lands decides the rest (`_answered`).
76
99
  */
77
100
  _gravityNow() {
78
101
  const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
79
102
  if (!(budget > 0)) return 'resize';
80
- // a size asked longer ago than the budget, and no frame of it yet
103
+ // a size asked longer ago than the budget, and no frame of any since
81
104
  const oldest = this._asked[0];
82
- if (oldest && now() - oldest.at > budget) this._behind = true;
105
+ if (oldest && now() - Math.max(oldest.at, this._answeredAt) > budget) {
106
+ this._behind = true;
107
+ }
83
108
  return this._behind ? 'resize' : 'topLeft';
84
109
  }
85
110
 
@@ -106,20 +131,35 @@ class PaneLayer {
106
131
  return { contentsScale: scale };
107
132
  }
108
133
 
109
- /** A frame of `width` by `height` device px landed: what it answers of
110
- * the sizes asked, and how long it took. */
134
+ /**
135
+ * A frame of `width` by `height` device px landed: what it answers of
136
+ * the sizes asked, and how long it took. Two frames in a row later than
137
+ * the budget put the pane behind, and one well inside it — `KEPT_UP` of
138
+ * it — brings it back: a frame the collector held up stretches nothing,
139
+ * and a pane whose frames come near the budget is not stretched and
140
+ * anchored by turns.
141
+ */
111
142
  _answered(width, height) {
112
143
  const asked = this._asked;
113
144
  // the pane rounds the logical size it was sent back to device pixels
114
145
  const slack = Math.max(2, this.wnd.scale ?? 1);
115
- const at = asked.findIndex(
146
+ // the last time it was asked: a drag back and forth asks a size twice
147
+ const at = asked.findLastIndex(
116
148
  (a) =>
117
149
  Math.abs(a.width - width) <= slack &&
118
150
  Math.abs(a.height - height) <= slack,
119
151
  );
120
152
  if (at < 0) return;
121
- this._behind =
122
- now() - asked[at].at > (this.app._resizeWait ?? RESIZE_WAIT_MS);
153
+ const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
154
+ const took = now() - asked[at].at;
155
+ this._answeredAt = now();
156
+ if (took > budget) {
157
+ this._late += 1;
158
+ if (this._late >= 2) this._behind = true;
159
+ } else {
160
+ this._late = 0;
161
+ if (took <= budget * KEPT_UP) this._behind = false;
162
+ }
123
163
  asked.splice(0, at + 1);
124
164
  }
125
165
 
@@ -144,6 +184,7 @@ class PaneLayer {
144
184
  if (at - (this._askedAt ?? -Infinity) >= GESTURE_GAP_MS) {
145
185
  this._asked = [];
146
186
  this._behind = false;
187
+ this._late = 0;
147
188
  }
148
189
  this._askedAt = at;
149
190
  if (this._asked.length === ASKED_KEPT) this._asked.shift();
package/src/offscreen.js CHANGED
@@ -5,13 +5,14 @@
5
5
  // An app that makes its own surfaces answers `createSurface(options)` — the
6
6
  // Cocoa app over a CG bitmap (src/cocoa/surface.js), the Windows app over a
7
7
  // Direct2D bitmap, the Wayland app over a GL render target
8
- // (src/wayland/surface.js) — and an X connection has no such method and gets
9
- // ntk's pixmap, from ntk's root, which only an X connection loads
10
- // (src/ntkroot.js). The result is whichever implementation answered, not an
11
- // instance of this class: the contract is the shape — `width`/`height`,
12
- // `getContext('2d')`, `render`, `clear`, `copyWithin`, `destroy`, and
13
- // `ctx.drawImage(surface, …)` — (docs/extending.md "Scrolling the pixels, not
14
- // just the offset"), and nothing needs `instanceof`.
8
+ // (src/wayland/surface.js), and ntk's own app over an X connection with its
9
+ // pixmap (ntk >= 8.21). An app without the method — an older ntk deduped
10
+ // into the tree — gets that pixmap from ntk's root, which only an X
11
+ // connection loads (src/ntkroot.js). The result is whichever implementation
12
+ // answered, not an instance of this class: the contract is the shape —
13
+ // `width`/`height`, `getContext('2d')`, `render`, `clear`, `copyWithin`,
14
+ // `destroy`, and `ctx.drawImage(surface, …)` — (docs/extending.md "Scrolling
15
+ // the pixels, not just the offset"), and nothing needs `instanceof`.
15
16
  //
16
17
  // Part of that shape is that a context may be **held**: ntk tells a caller
17
18
  // doing many draws to take `getContext('2d')` once rather than one per frame,
@@ -407,20 +407,68 @@ class Win32TextLayout {
407
407
  * where CoreText cannot and the Cocoa engine breaks at `linebreak`'s
408
408
  * opportunities itself. */
409
409
  get minWidth() {
410
+ // asked of the bridge the first time, where it left it out of the
411
+ // metrics: the line breaker over the whole text again, which a layout
412
+ // made to be drawn is never asked for
413
+ if (this._metrics.minWidth === undefined) {
414
+ this._metrics.minWidth =
415
+ this._handle != null && this._native.layoutMinWidth
416
+ ? (this._native.layoutMinWidth(this._handle) ?? 0)
417
+ : 0;
418
+ }
410
419
  return this._metrics.minWidth;
411
420
  }
412
421
 
422
+ /**
423
+ * Keep the first `cap` lines of what was laid out: they are all this
424
+ * layout reports, answers for and draws (`_keptEnd`, `_keptHeight`). A
425
+ * capped paragraph is laid out past its cap to find where the cap falls,
426
+ * and keeping that layout's first lines is the same picture as laying the
427
+ * kept text out again, for one native layout where it took two.
428
+ */
429
+ _keep(cap) {
430
+ const lines = this._metrics.lines.slice(0, cap);
431
+ const last = lines[lines.length - 1];
432
+ if (!last) return;
433
+ let width = 0;
434
+ for (const line of lines)
435
+ width = Math.max(width, Math.ceil(line.width - 1e-3));
436
+ this._metrics = {
437
+ ...this._metrics,
438
+ lines,
439
+ width,
440
+ height: Math.ceil(last.y + last.height),
441
+ minWidth: undefined,
442
+ };
443
+ this._keptEnd = last.end;
444
+ this._keptHeight = last.y + last.height;
445
+ }
446
+
413
447
  draw(ctx, x, y) {
448
+ if (this._keptHeight === undefined) {
449
+ ctx._drawLayout(this, x, y);
450
+ return;
451
+ }
452
+ // the lines past the cap are in the layout, and stay out of the picture
453
+ ctx.save();
454
+ ctx.beginPath();
455
+ ctx.rect(x - 1e4, y, 2e4 + this._metrics.width, this._keptHeight);
456
+ ctx.clip();
414
457
  ctx._drawLayout(this, x, y);
458
+ ctx.restore();
415
459
  }
416
460
 
417
461
  indexAt(x, y) {
418
462
  const unit = this._native.layoutIndexAt(this._handle, x, y);
419
- return this._cpOf(unit);
463
+ return this._cpOf(Math.min(unit, this._keptEnd ?? unit));
420
464
  }
421
465
 
422
466
  caretPosition(cp) {
423
- return this._native.layoutCaret(this._handle, this._cuOf(cp));
467
+ const unit = this._cuOf(cp);
468
+ return this._native.layoutCaret(
469
+ this._handle,
470
+ Math.min(unit, this._keptEnd ?? unit),
471
+ );
424
472
  }
425
473
 
426
474
  /**
@@ -567,6 +615,20 @@ export class Win32FontManager {
567
615
  // family|weight|italic|size -> the bridge's handle, or null when there is
568
616
  // no such face
569
617
  this._handles = new Map();
618
+ // what this bridge's layouts take, asked once (`textFeatures`, from
619
+ // @windowkit/win32 0.0.9): an older one answers nothing and takes neither
620
+ this._features = native.textFeatures?.() ?? {};
621
+ }
622
+
623
+ /**
624
+ * Which of ntk's `justify` modes `layout` takes. DirectWrite justifies
625
+ * every line but a paragraph's last, which is `justify: true`, and has no
626
+ * way to justify a last line: `'rest'` says so, and a caller justifies the
627
+ * other two itself. <Html>, which spaced every justified line through
628
+ * letter spacing on this engine, lays a justified paragraph out once.
629
+ */
630
+ get justifies() {
631
+ return this._features.justify === true ? 'rest' : false;
570
632
  }
571
633
 
572
634
  /**
@@ -759,7 +821,9 @@ export class Win32FontManager {
759
821
  return this._laidOut(sources, base, { ...options, maxWidth: least });
760
822
  }
761
823
  let laid = sources;
762
- let layout = this._laidOut(laid, base, options);
824
+ let layout =
825
+ this._prefixLaidOut(sources, base, options, cap) ??
826
+ this._laidOut(laid, base, options);
763
827
  // With no width the bridge lays a paragraph out in a box a million
764
828
  // pixels wide, and aligns it there: a centred line's `x` was half a
765
829
  // million, an RTL one's a million, and <Html> measured a centred
@@ -776,7 +840,11 @@ export class Win32FontManager {
776
840
  layout = this._laidOut(laid, base, { ...options, maxWidth: widest });
777
841
  }
778
842
  layout.truncated = layout.lines.length > cap;
779
- if (layout.truncated) {
843
+ if (layout.truncated && !elides) {
844
+ // what was laid out, a prefix or the whole, keeps its first lines
845
+ laid = spansTo(sources, layout._text.length);
846
+ layout._keep(cap);
847
+ } else if (layout.truncated) {
780
848
  const last = layout.lines[cap - 1];
781
849
  const text = layout._text;
782
850
  layout.destroy();
@@ -800,6 +868,43 @@ export class Win32FontManager {
800
868
  return layout;
801
869
  }
802
870
 
871
+ /**
872
+ * A capped paragraph laid out only as far as the cap needs: a prefix of
873
+ * its text that breaks into more lines than `cap`, or null where the
874
+ * whole text is no longer than a prefix would be. The first lines of a
875
+ * prefix are the whole text's first lines wherever it reaches past them —
876
+ * a line's break waits only on the word after it, which then starts a
877
+ * line of the prefix — and the prefix ends at a break, its last word
878
+ * whole. <Html> lays a paragraph beside a float out a line at a time,
879
+ * each `maxLines: 1` over the rest of the paragraph; laid out whole, each
880
+ * line cost the whole rest of it, twice, and a paragraph cost the square
881
+ * of its length.
882
+ */
883
+ _prefixLaidOut(sources, base, options, cap) {
884
+ const { maxWidth } = options;
885
+ if (!(cap < Infinity) || !(maxWidth > 0) || !Number.isFinite(maxWidth)) {
886
+ return null;
887
+ }
888
+ const text = sources.map((span) => String(span.text)).join('');
889
+ let size = Infinity;
890
+ for (const span of sources)
891
+ size = Math.min(size, sizeOf(span.size ?? base.size));
892
+ // a third of an em a character is narrower than nearly any text is set
893
+ // in, so a prefix that long nearly always reaches past the lines wanted;
894
+ // where it does not, one twice as long is tried
895
+ let take = Math.ceil(maxWidth / (Math.max(1, size) * 0.3)) * cap + 16;
896
+ while (take < text.length) {
897
+ let end = take;
898
+ while (end < text.length && !BREAKING_SPACE.test(text[end])) end++;
899
+ if (end >= text.length) return null;
900
+ const layout = this._laidOut(spansTo(sources, end), base, options);
901
+ if (layout.lines.length > cap) return layout;
902
+ layout.destroy();
903
+ take *= 2;
904
+ }
905
+ return null;
906
+ }
907
+
803
908
  /**
804
909
  * Where the last line kept of an eliding paragraph ends: as far into
805
910
  * `[start, end)` as fits beside the mark in `maxWidth`, white space before
@@ -899,11 +1004,17 @@ export class Win32FontManager {
899
1004
  align: options.align,
900
1005
  lineHeight: options.lineHeight,
901
1006
  rtl: options.direction === 'rtl',
1007
+ // the one mode DirectWrite has (`justifies`); 'last' and 'all' are
1008
+ // the caller's
1009
+ justify: options.justify === true || undefined,
902
1010
  },
903
1011
  ranges,
904
1012
  );
905
1013
 
906
- const raw = this._native.layoutMetrics(handle);
1014
+ const raw = this._native.layoutMetrics(
1015
+ handle,
1016
+ this._features.lazyMinWidth ? { minWidth: false } : undefined,
1017
+ );
907
1018
  if (DEBUG) {
908
1019
  // The family is in here because the one way this goes quietly wrong is
909
1020
  // `resolveStack` not recognising a name and answering `sans-serif`: the