react-x11 2.22.7 → 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.7",
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.14.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",
@@ -150,6 +150,15 @@ function unpackLines(lineData = NO_GEOMETRY, runData = NO_GEOMETRY) {
150
150
  return lines;
151
151
  }
152
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
+ }
161
+
153
162
  class CocoaTextLayout {
154
163
  constructor(native, raw, text) {
155
164
  this._native = native;
@@ -177,6 +186,118 @@ class CocoaTextLayout {
177
186
  return t.length - 1;
178
187
  }
179
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
+
180
301
  draw(ctx, x, y) {
181
302
  ctx._drawLayout(this, x, y);
182
303
  }
@@ -1084,17 +1205,20 @@ export class CocoaFontManager {
1084
1205
  // refresh LRU position
1085
1206
  this._layouts.delete(signature);
1086
1207
  this._layouts.set(signature, hit);
1087
- return hit;
1208
+ return hit._forSources(spans.filter((span) => String(span.text ?? '')));
1088
1209
  }
1089
1210
  const { maxWidth, align, lineHeight, maxLines, overflow, direction } =
1090
1211
  options;
1091
1212
  const nativeSpans = [];
1213
+ // the caller's spans with text, which the runs hang off (`_hangRuns`)
1214
+ const sources = [];
1092
1215
  let text = '';
1093
1216
  let contextInk = false;
1094
1217
  for (const span of spans) {
1095
1218
  const t = String(span.text ?? '');
1096
1219
  if (!t) continue;
1097
1220
  text += t;
1221
+ sources.push(span);
1098
1222
  const size = span.size ?? base.size ?? 14;
1099
1223
  const variations = span.variations ?? base.variations;
1100
1224
  // A span may carry the face itself — an ntk Font from openFont(),
@@ -1191,6 +1315,40 @@ export class CocoaFontManager {
1191
1315
  minContent ? laid.map((span) => span.text).join('') : text,
1192
1316
  );
1193
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
+ }
1194
1352
  this._layouts.set(signature, layout);
1195
1353
  if (this._layouts.size > 64) {
1196
1354
  this._layouts.delete(this._layouts.keys().next().value);
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),