react-x11 2.22.6 → 2.22.8

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.8",
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.15.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,62 @@ 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
+ }
152
+
153
+ /** Whether two spans read the same, field for field. */
154
+ function sameSpan(a, b) {
155
+ if (a === b) return true;
156
+ const keys = Object.keys(a);
157
+ if (keys.length !== Object.keys(b).length) return false;
158
+ for (const key of keys) if (a[key] !== b[key]) return false;
159
+ return true;
160
+ }
110
161
 
111
162
  class CocoaTextLayout {
112
163
  constructor(native, raw, text) {
@@ -116,21 +167,137 @@ class CocoaTextLayout {
116
167
  this._cpToCu = codeUnitOffsets(text);
117
168
  this.width = raw.width;
118
169
  this.height = raw.height;
119
- this.lines = raw.lines;
170
+ this.lines = raw.lines ?? unpackLines(raw.lineData, raw.runData);
120
171
  }
121
172
 
122
173
  _cuOf(cp) {
123
174
  const t = this._cpToCu;
175
+ if (t === null) return Math.max(0, Math.min(cp, this._text.length));
124
176
  return t[Math.max(0, Math.min(cp, t.length - 1))];
125
177
  }
126
178
 
127
179
  _cpOf(cu) {
128
180
  const t = this._cpToCu;
181
+ if (t === null) {
182
+ return cu <= 0 ? 0 : Math.min(Math.ceil(cu), this._text.length);
183
+ }
129
184
  // t is sorted; layouts are short, a linear walk is fine
130
185
  for (let i = 0; i < t.length; i++) if (t[i] >= cu) return i;
131
186
  return t.length - 1;
132
187
  }
133
188
 
189
+ /**
190
+ * Hang each run off the span it was laid out from, as ntk's runs hang:
191
+ * `span` is the caller's own object, markers and all — what a painter
192
+ * reads a link's underline or a code chip's background off — and `run`
193
+ * the face and size it was set in, and its direction. CoreText makes one
194
+ * run of two neighbouring spans with the same attributes, a link beside
195
+ * plain text of the same colour, so such a run is cut where the spans
196
+ * meet, at the offset the native gives for that index.
197
+ */
198
+ _hangRuns(sources, runOf) {
199
+ this._sources = sources;
200
+ const starts = [];
201
+ let at = 0;
202
+ for (const source of sources) {
203
+ starts.push(at);
204
+ at += String(source.text).length;
205
+ }
206
+ for (const line of this.lines) {
207
+ const runs = [];
208
+ for (const r of line.runs) this._cutRun(r, line, starts, runOf, runs);
209
+ line.runs = runs;
210
+ }
211
+ }
212
+
213
+ _cutRun(r, line, starts, runOf, out) {
214
+ // the span holding the run's first unit
215
+ let lo = 0;
216
+ let hi = starts.length - 1;
217
+ while (lo < hi) {
218
+ const mid = (lo + hi + 1) >> 1;
219
+ if (starts[mid] <= r.start) lo = mid;
220
+ else hi = mid - 1;
221
+ }
222
+ const hang = (run, source) => {
223
+ run.span = this._sources[source];
224
+ run.run = runOf(source, run.rtl);
225
+ // which span, for a layout found again with another call's spans; out
226
+ // of sight, so a run is ntk's shape to anything that reads it whole
227
+ Object.defineProperty(run, '_source', { value: source });
228
+ return run;
229
+ };
230
+ let next = lo + 1;
231
+ if (next >= starts.length || starts[next] >= r.end) {
232
+ out.push(hang(r, lo));
233
+ return;
234
+ }
235
+ // where each span that starts inside the run begins, from the line's
236
+ // origin, as the run's own `x` is measured
237
+ const edges = [r.start];
238
+ const at = [r.rtl ? r.x + r.width : r.x];
239
+ const sources = [lo];
240
+ for (; next < starts.length && starts[next] < r.end; next += 1) {
241
+ edges.push(starts[next]);
242
+ at.push(this._native.layoutCaret(this._handle, starts[next]).x - line.x);
243
+ sources.push(next);
244
+ }
245
+ edges.push(r.end);
246
+ at.push(r.rtl ? r.x : r.x + r.width);
247
+ const pieces = [];
248
+ for (let i = 0; i < sources.length; i += 1) {
249
+ const a = at[i];
250
+ const b = at[i + 1];
251
+ pieces.push(
252
+ hang(
253
+ {
254
+ ...r,
255
+ start: edges[i],
256
+ end: edges[i + 1],
257
+ x: Math.min(a, b),
258
+ width: Math.abs(b - a),
259
+ },
260
+ sources[i],
261
+ ),
262
+ );
263
+ }
264
+ // a line's runs go left to right, and a right-to-left run's first span
265
+ // is its rightmost piece
266
+ if (r.rtl) pieces.reverse();
267
+ out.push(...pieces);
268
+ }
269
+
270
+ /**
271
+ * This layout for another call's spans: the same text set the same way,
272
+ * which is what found it in the cache. Spans that are the same, or read
273
+ * the same field for field, are the same layout — an immediate-mode caller
274
+ * that builds its spans afresh every paint is answered with the object it
275
+ * had. A caller whose markers differ — a link here, plain text there — has
276
+ * the runs pointed at its own spans, in a copy.
277
+ */
278
+ _forSources(sources) {
279
+ const mine = this._sources;
280
+ if (
281
+ !mine ||
282
+ (mine.length === sources.length &&
283
+ mine.every((source, i) => sameSpan(source, sources[i])))
284
+ ) {
285
+ return this;
286
+ }
287
+ const copy = Object.create(CocoaTextLayout.prototype);
288
+ Object.assign(copy, this);
289
+ copy._sources = sources;
290
+ copy.lines = this.lines.map((line) => ({
291
+ ...line,
292
+ runs: line.runs.map((r) => {
293
+ const run = { ...r, span: sources[r._source] };
294
+ Object.defineProperty(run, '_source', { value: r._source });
295
+ return run;
296
+ }),
297
+ }));
298
+ return copy;
299
+ }
300
+
134
301
  draw(ctx, x, y) {
135
302
  ctx._drawLayout(this, x, y);
136
303
  }
@@ -500,6 +667,10 @@ function spacedFeatures(features, spacing) {
500
667
  : OPTIONAL_LIGATURES_OFF;
501
668
  }
502
669
 
670
+ /** The text the kept typesetters may hold, in UTF-16 units: a document of
671
+ * about a megabyte, at 25 bytes a unit in CoreText. */
672
+ const KEPT_TYPESETTER_UNITS = 1 << 20;
673
+
503
674
  export class CocoaFontManager {
504
675
  /** @param native the @windowkit/appkit module; the tests hand in a fake */
505
676
  constructor(native = loadNative()) {
@@ -513,6 +684,50 @@ export class CocoaFontManager {
513
684
  this._sized = new Map(); // face key|size|variations -> CTFont handle
514
685
  this._layouts = new Map(); // layout signature -> CocoaTextLayout (LRU)
515
686
  this._faceByPs = new Map(); // PostScript name -> face CoreText chose itself
687
+ // paragraph -> its typesetter, kept to lay it out at another width
688
+ // (`_keptTypesetter`); LRU, bounded by the UTF-16 units they hold. Only
689
+ // a bridge that keeps them (`@windowkit/appkit` 0.14) has the verb.
690
+ this._keeps = typeof native.releaseTypesetter === 'function';
691
+ this._typesetters = new Map();
692
+ this._typesetterUnits = 0;
693
+ }
694
+
695
+ /**
696
+ * A paragraph's typesetter, kept from a layout of the same text at any
697
+ * width. Two thirds of a layout is the text becoming glyphs — the
698
+ * attributed string, then CoreText's shaping — and a paragraph laid out
699
+ * at another width breaks the same glyphs into other lines: a window
700
+ * resize lays a document out at every width it passes through, and a
701
+ * paragraph's max-content and wrapped measurements are two layouts of
702
+ * one text. The cache holds `KEPT_TYPESETTER_UNITS` of text, about
703
+ * 25 MB, which is a long document; past that the paragraph laid out
704
+ * least recently is let go.
705
+ */
706
+ _keptTypesetter(key) {
707
+ const kept = this._typesetters.get(key);
708
+ if (kept === undefined) return null;
709
+ this._typesetters.delete(key);
710
+ this._typesetters.set(key, kept);
711
+ return kept.handle;
712
+ }
713
+
714
+ _keepTypesetter(key, handle, units) {
715
+ this._typesetters.set(key, { handle, units });
716
+ this._typesetterUnits += units;
717
+ while (this._typesetterUnits > KEPT_TYPESETTER_UNITS) {
718
+ const [oldest, entry] = this._typesetters.entries().next().value;
719
+ this._typesetters.delete(oldest);
720
+ this._typesetterUnits -= entry.units;
721
+ this._native.releaseTypesetter(entry.handle);
722
+ }
723
+ }
724
+
725
+ _forgetTypesetters() {
726
+ for (const { handle } of this._typesetters.values()) {
727
+ this._native.releaseTypesetter(handle);
728
+ }
729
+ this._typesetters.clear();
730
+ this._typesetterUnits = 0;
516
731
  }
517
732
 
518
733
  /** A face the app loaded joins every fallback chain: what the faces that
@@ -941,6 +1156,7 @@ export class CocoaFontManager {
941
1156
  this._faces.clear();
942
1157
  this._sized.clear();
943
1158
  this._layouts.clear();
1159
+ if (this._keeps) this._forgetTypesetters();
944
1160
  this._forgetCovers();
945
1161
  // `null` on purpose: src/fonts.js keeps the fontkit face it already
946
1162
  // opened as the handle, which is the one whose metrics apps can read;
@@ -957,8 +1173,10 @@ export class CocoaFontManager {
957
1173
  // Memoized: an immediate-mode caller (the fonts app's specimen canvas,
958
1174
  // a hover pass) lays the same paragraph out every repaint, and a
959
1175
  // 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([
1176
+ // is everything shaping reads — the paragraph, `shape`, which is also
1177
+ // what its kept typesetter is found by — and then everything breaking
1178
+ // it into lines reads; ~64 entries covers a screenful.
1179
+ const shape = JSON.stringify([
962
1180
  spans.map((sp) => [
963
1181
  sp.text,
964
1182
  sp.family ?? base.family,
@@ -971,29 +1189,36 @@ export class CocoaFontManager {
971
1189
  sp.features ?? base.features ?? null,
972
1190
  sp.letterSpacing ?? base.letterSpacing ?? 0,
973
1191
  ]),
974
- options.maxWidth,
975
- options.align,
976
- options.lineHeight,
977
- options.maxLines,
978
- options.overflow,
979
1192
  options.direction,
980
1193
  ]);
1194
+ const signature =
1195
+ shape +
1196
+ JSON.stringify([
1197
+ options.maxWidth,
1198
+ options.align,
1199
+ options.lineHeight,
1200
+ options.maxLines,
1201
+ options.overflow,
1202
+ ]);
981
1203
  const hit = this._layouts.get(signature);
982
1204
  if (hit) {
983
1205
  // refresh LRU position
984
1206
  this._layouts.delete(signature);
985
1207
  this._layouts.set(signature, hit);
986
- return hit;
1208
+ return hit._forSources(spans.filter((span) => String(span.text ?? '')));
987
1209
  }
988
1210
  const { maxWidth, align, lineHeight, maxLines, overflow, direction } =
989
1211
  options;
990
1212
  const nativeSpans = [];
1213
+ // the caller's spans with text, which the runs hang off (`_hangRuns`)
1214
+ const sources = [];
991
1215
  let text = '';
992
1216
  let contextInk = false;
993
1217
  for (const span of spans) {
994
1218
  const t = String(span.text ?? '');
995
1219
  if (!t) continue;
996
1220
  text += t;
1221
+ sources.push(span);
997
1222
  const size = span.size ?? base.size ?? 14;
998
1223
  const variations = span.variations ?? base.variations;
999
1224
  // A span may carry the face itself — an ntk Font from openFont(),
@@ -1054,8 +1279,17 @@ export class CocoaFontManager {
1054
1279
  : minContent
1055
1280
  ? brokenAtEveryOpportunity(nativeSpans)
1056
1281
  : nativeSpans;
1282
+ // The paragraph's kept typesetter, where it has one. A min-content
1283
+ // measurement lays out other text — the paragraph broken at every
1284
+ // opportunity, and capped for an eliding one — and keeps its own.
1285
+ const shapeKey = probe
1286
+ ? `${shape}|${maxLines}`
1287
+ : minContent
1288
+ ? `${shape}|min`
1289
+ : shape;
1290
+ const typesetter = this._keeps ? this._keptTypesetter(shapeKey) : null;
1057
1291
  const raw = this._native.createLayout({
1058
- spans: laid,
1292
+ ...(typesetter ? { typesetter } : { spans: laid }),
1059
1293
  maxWidth:
1060
1294
  Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : undefined,
1061
1295
  align: flushFor(align, direction),
@@ -1063,7 +1297,15 @@ export class CocoaFontManager {
1063
1297
  maxLines: probe || !Number.isFinite(maxLines) ? undefined : maxLines,
1064
1298
  ellipsis: !probe && overflow === 'ellipsis',
1065
1299
  rtl: direction === 'rtl',
1300
+ // the geometry as two arrays rather than an object a line and a run:
1301
+ // a tenth of the calls across the bridge (`unpackLines`)
1302
+ ...(this._keeps ? { keep: typesetter === null, packed: true } : null),
1066
1303
  });
1304
+ if (raw.typesetter) {
1305
+ let units = 0;
1306
+ for (const span of laid) units += span.text.length;
1307
+ this._keepTypesetter(shapeKey, raw.typesetter, units);
1308
+ }
1067
1309
  // the text the native actually laid out: a min-content measurement's
1068
1310
  // line and run ranges are indices into the broken text, and the two have
1069
1311
  // to agree for `indexAt`/`caretPosition` to mean anything at all
@@ -1073,6 +1315,40 @@ export class CocoaFontManager {
1073
1315
  minContent ? laid.map((span) => span.text).join('') : text,
1074
1316
  );
1075
1317
  layout._contextInk = contextInk;
1318
+ if (!minContent) {
1319
+ const runs = new Map();
1320
+ layout._hangRuns(sources, (i, rtl) => {
1321
+ const key = `${i}|${rtl ? 1 : 0}`;
1322
+ let run = runs.get(key);
1323
+ if (!run) {
1324
+ const span = sources[i];
1325
+ const given = span.font ?? base.font;
1326
+ run = {
1327
+ font:
1328
+ typeof given?.metrics === 'function'
1329
+ ? given
1330
+ : this.match(span.family ?? base.family, {
1331
+ weight: span.weight ?? base.weight,
1332
+ style: span.style ?? base.style,
1333
+ }),
1334
+ size: span.size ?? base.size ?? 14,
1335
+ direction: rtl ? 'rtl' : 'ltr',
1336
+ };
1337
+ runs.set(key, run);
1338
+ }
1339
+ return run;
1340
+ });
1341
+ // ntk's `truncated`: whether a line cap dropped anything. An eliding
1342
+ // layout does not say — its last line is cut to the mark, and where
1343
+ // the cut fell is the native's — so a caller asks the line ends.
1344
+ if (!elides) {
1345
+ const last = layout.lines[layout.lines.length - 1];
1346
+ layout.truncated =
1347
+ Number.isFinite(maxLines) &&
1348
+ !!last &&
1349
+ /\S/.test(text.slice(last.end));
1350
+ }
1351
+ }
1076
1352
  this._layouts.set(signature, layout);
1077
1353
  if (this._layouts.size > 64) {
1078
1354
  this._layouts.delete(this._layouts.keys().next().value);
@@ -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
  }
package/src/nodes/text.js CHANGED
@@ -56,21 +56,6 @@ const textStripBelowFor = (app) =>
56
56
  ? textStripBelowEnv
57
57
  : TEXT_STRIP_BELOW);
58
58
 
59
- /**
60
- * Downward shift that recreates CSS "half-leading". ntk's TextLayout puts
61
- * the first baseline at exactly `ascent` and packs each line's leading
62
- * (font line gap + any lineHeight surplus) entirely *below* the glyphs, so
63
- * a layout drawn at the top of its measured box rides visually high —
64
- * most noticeable centered in buttons/inputs (fonts like Helvetica carry a
65
- * 0.5em line gap). CSS instead splits that leading evenly above and below
66
- * the ink (see seek-oss capsize for the metrics background).
67
- */
68
- function halfLeading(layout) {
69
- const last = layout.lines?.[layout.lines.length - 1];
70
- if (!last) return 0;
71
- return Math.max(0, (layout.height - (last.baseline + last.descent)) / 2);
72
- }
73
-
74
59
  /** A selected line with nothing on it still shows, so a blank line inside a
75
60
  * selection does not read as the highlight having stopped. */
76
61
  const EMPTY_LINE_BAND = 4;
@@ -469,9 +454,10 @@ export class TextNode extends Node {
469
454
  * ever optically even by luck. `lineHeight` cannot fix it: it scales the
470
455
  * box and the leading still splits evenly, so it moves both edges alike.
471
456
  *
472
- * Measured in the coordinates the layout is **drawn** in, not the ones it
473
- * reports: `halfLeading` shifts it, and deriving the baseline from the
474
- * metrics again would silently disagree the day that shift changes.
457
+ * Measured from the baselines the layout reports, which are where it
458
+ * draws them: the engine splits a line's leading above and below the
459
+ * glyphs, and deriving the baseline from the metrics again would silently
460
+ * disagree with it.
475
461
  *
476
462
  * The amounts are fractions of a pixel and stay that way — the glyphs are
477
463
  * placed from them (`_placedLayout`). What must not stay fractional is the
@@ -505,9 +491,8 @@ export class TextNode extends Node {
505
491
  // one backend and half-covers two rows on the other. Rounded, the
506
492
  // baseline sits on a pixel wherever the box does.
507
493
  const capHeight = Math.round(measured);
508
- const shift = halfLeading(layout);
509
- const firstBaseline = shift + lines[0].baseline;
510
- const lastBaseline = shift + lines[lines.length - 1].baseline;
494
+ const firstBaseline = lines[0].baseline;
495
+ const lastBaseline = lines[lines.length - 1].baseline;
511
496
  return {
512
497
  top: Math.max(0, firstBaseline - capHeight),
513
498
  bottom: Math.max(0, Math.ceil(layout.height) - lastBaseline),
@@ -530,7 +515,7 @@ export class TextNode extends Node {
530
515
  return {
531
516
  layout,
532
517
  x: content.x,
533
- y: content.y + halfLeading(layout) - (trim ? trim.top : 0),
518
+ y: content.y - (trim ? trim.top : 0),
534
519
  };
535
520
  }
536
521
 
@@ -618,15 +618,14 @@ export class TextInputNode extends Node {
618
618
  * it fills. Centred on the **capitals**, not on the line box and not on
619
619
  * the ink.
620
620
  *
621
- * The layout box carries the line's leading entirely below the glyphs, so
622
- * centring that pushes the text visually up (see `halfLeading`). Centring
623
- * ascent + descent — what this did — fixes the leading but not the
624
- * asymmetry underneath it: a font's ascent clears its capitals by
625
- * `ascent - capHeight`, which is not its descent, so a single line of
626
- * text sits off-centre by a number that belongs to the typeface. At 14px
627
- * that is 0.7px of extra space above the capitals in SF NS and 2.5px the
628
- * other way in Helvetica — visible in a field, where there is one short
629
- * line and a border close on both sides to measure it against.
621
+ * Centring the line box centres its ascent + descent, because the engine
622
+ * splits the leading evenly round them, and that is not enough: a font's
623
+ * ascent clears its capitals by `ascent - capHeight`, which is not its
624
+ * descent, so a single line of text sits off-centre by a number that
625
+ * belongs to the typeface. At 14px that is 0.7px of extra space above the
626
+ * capitals in SF NS and 2.5px the other way in Helvetica — visible in a
627
+ * field, where there is one short line and a border close on both sides
628
+ * to measure it against.
630
629
  *
631
630
  * So: put the baseline where the space above the capitals equals the
632
631
  * space under it. A `<text>` says the same thing as `textBoxTrim`, but a
@@ -486,9 +486,9 @@ export class Win32FontManager {
486
486
  );
487
487
  }
488
488
 
489
- // `descent` is what halfLeading() in nodes/text.js subtracts to recreate
490
- // CSS half-leading, and DirectWrite reports a baseline and a height per
491
- // line rather than a descent.
489
+ // Every engine answers a descent per line — a field and the edit menu
490
+ // measure a line's ink as its ascent plus its descent — and DirectWrite
491
+ // reports a baseline and a height per line rather than a descent.
492
492
  const lines = (raw.lines ?? []).map((line) => ({
493
493
  ...line,
494
494
  descent: Math.max(0, line.height - line.baseline),