react-x11 2.3.1 → 2.4.0

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.3.1",
3
+ "version": "2.4.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -90,7 +90,7 @@
90
90
  "yoga-layout": "^3.2.1"
91
91
  },
92
92
  "optionalDependencies": {
93
- "@windowkit/appkit": "^0.1.0",
93
+ "@windowkit/appkit": "^0.3.0",
94
94
  "dbus-native": "^0.15.1",
95
95
  "x11-dri": "^0.7.0"
96
96
  },
@@ -65,6 +65,32 @@ class LinearGradient {
65
65
  }
66
66
  }
67
67
 
68
+ const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
69
+
70
+ /**
71
+ * The Render ops text draws with, numbered as XRender numbers them so a
72
+ * caller's `ctx.Render?.PictOp?.Over ?? 3` reads the same on both
73
+ * backends. Every op draws as Over here: the bridge composites glyph
74
+ * coverage with the context's fill and offers no blend-mode switch, and
75
+ * for the opaque inks text uses Src and Over agree.
76
+ */
77
+ const PICT_OP = Object.freeze({ Src: 1, Over: 3 });
78
+ const RENDER = Object.freeze({ PictOp: PICT_OP });
79
+
80
+ /**
81
+ * A solid ink for `drawGlyphs` — ntk's `createSolidPicture` answers an
82
+ * XRender picture; here it is the colour itself. Premultiplied 0..1 in, as
83
+ * XRender solids are (the identity for the opaque inks text uses), straight
84
+ * for CoreGraphics inside.
85
+ */
86
+ class SolidPicture {
87
+ constructor(r, g, b, a) {
88
+ const alpha = clamp01(a);
89
+ const straight = (c) => (alpha > 0 ? clamp01(c / alpha) : 0);
90
+ this._rgba = [straight(r), straight(g), straight(b), alpha];
91
+ }
92
+ }
93
+
68
94
  export class CocoaContext2D {
69
95
  /**
70
96
  * @param native the @windowkit/appkit module
@@ -617,6 +643,107 @@ export class CocoaContext2D {
617
643
  return promise;
618
644
  }
619
645
 
646
+ // --- glyph runs (ntk's documented run contract) --------------------------
647
+
648
+ /** ntk's Render extension object, as much of it as text needs. */
649
+ get Render() {
650
+ return RENDER;
651
+ }
652
+
653
+ createSolidPicture(r, g, b, a) {
654
+ return new SolidPicture(r, g, b, a);
655
+ }
656
+
657
+ /**
658
+ * Composite glyph runs — ntk's contract (its docs/text.md#glyph-runs),
659
+ * so a renderer written against ntk's context runs here unchanged:
660
+ * `positioned` is `[{ run: { font, size, glyphs: [{ id, ax, dx, dy }] },
661
+ * x, y }]`, `x`/`y` the run's baseline origin in user space, the pen
662
+ * starting at `x` and each glyph inking at `(pen + dx, y - dy)` — `dy`
663
+ * y-up — before advancing by `ax`. `op` is `Render.PictOp.Over` or
664
+ * `.Src`; `src` a `createSolidPicture` ink.
665
+ *
666
+ * The glyphs are grouped by face and size and go out as one native call
667
+ * — `CTFontDrawGlyphs` per group, with the fill set to `src`'s colour —
668
+ * so a frame of terminal text is one call per foreground colour.
669
+ * `run.font` is a face from `fonts.match()`/`fallbackFor()`, or an ntk
670
+ * `Font` from `openFont()` (resolved to CoreText from the same bytes, so
671
+ * its glyph ids hold); a glyph carrying a `font` of its own — what
672
+ * `shape()` produces when CoreText substituted a face — draws with that
673
+ * face.
674
+ *
675
+ * One difference from ntk, stated: the transform applies to the glyphs as
676
+ * well as to their origins, because CoreGraphics draws text through the
677
+ * CTM like everything else, where ntk moves the origins and keeps the
678
+ * advances in device pixels. Under a translate, which is what a node's
679
+ * paint runs in, the two agree.
680
+ */
681
+ drawGlyphs(op, src, positioned) {
682
+ if (!Array.isArray(positioned) || positioned.length === 0) return;
683
+ const fonts = this._fonts;
684
+ if (typeof fonts?._runHandle !== 'function') return;
685
+ const batches = new Map(); // CTFont handle -> { font, glyphs, positions }
686
+ for (const placed of positioned) {
687
+ const run = placed?.run;
688
+ const glyphs = run?.glyphs;
689
+ if (!glyphs?.length) continue;
690
+ const size = run.size;
691
+ const runHandle = fonts._runHandle(run.font, size);
692
+ let pen = 0;
693
+ for (const g of glyphs) {
694
+ const handle = g.font ? fonts._runHandle(g.font, size) : runHandle;
695
+ if (handle) {
696
+ let batch = batches.get(handle);
697
+ if (!batch) {
698
+ batch = { font: handle, glyphs: [], positions: [] };
699
+ batches.set(handle, batch);
700
+ }
701
+ batch.glyphs.push(g.id);
702
+ batch.positions.push(
703
+ placed.x + pen + (g.dx || 0),
704
+ placed.y - (g.dy || 0),
705
+ );
706
+ }
707
+ pen += g.ax || 0;
708
+ }
709
+ }
710
+ if (batches.size === 0) return;
711
+ const [r, g, b, a] = this._inkOf(src);
712
+ const surface = this._s();
713
+ this._native.ctxSetFillColor(surface, r, g, b, a);
714
+ const runs = [];
715
+ for (const batch of batches.values()) {
716
+ runs.push({
717
+ font: batch.font,
718
+ glyphs: Uint16Array.from(batch.glyphs),
719
+ positions: Float64Array.from(batch.positions),
720
+ });
721
+ }
722
+ this._native.ctxDrawGlyphs(surface, runs);
723
+ this._dirty();
724
+ }
725
+
726
+ /**
727
+ * The straight colour a `drawGlyphs` source paints with: a solid ink, a
728
+ * CSS colour string, a straight `[r, g, b, a]` — or, for anything else
729
+ * (a gradient, which glyph runs do not fill through here), the fill
730
+ * style in force.
731
+ */
732
+ _inkOf(src) {
733
+ if (src instanceof SolidPicture) return src._rgba;
734
+ if (typeof src === 'string') return parseColor(src);
735
+ if (Array.isArray(src) && src.length >= 3) {
736
+ return [
737
+ clamp01(src[0]),
738
+ clamp01(src[1]),
739
+ clamp01(src[2]),
740
+ src.length > 3 ? clamp01(src[3]) : 1,
741
+ ];
742
+ }
743
+ const style = this._state.fillStyle;
744
+ return style instanceof LinearGradient ? BLACK : parseColor(style);
745
+ }
746
+
620
747
  // --- text (minimal: enough for <canvas onDraw> users) --------------------
621
748
 
622
749
  _drawLayout(layout, x, y) {
@@ -6,7 +6,15 @@
6
6
  // overflow, direction })
7
7
  // -> { width, height, lines, draw(ctx, x, y),
8
8
  // indexAt(x, y), caretPosition(cp) }
9
- // fonts.match(family, { weight, style }) -> { metrics(size) }
9
+ // fonts.match(family, { weight, style }) -> face
10
+ // fonts.fallbackFor(codepoint, family, { weight, style }) -> face | null
11
+ //
12
+ // and a face is ntk's `Font` as far as a renderer that positions glyphs
13
+ // itself reads it — `metrics(size)`, `hasGlyph(cp)`, `glyphIdFor(cp)`,
14
+ // `advanceOf(id, size)`, `shape(text, size)` — so a run built here draws
15
+ // through `ctx.drawGlyphs` here (issue #432, over @windowkit/appkit 0.3's
16
+ // fontGlyphForCodepoint / fontGlyphAdvances / fontFallbackFor /
17
+ // fontShapeText / fontWithSize / ctxDrawGlyphs).
10
18
  //
11
19
  // Index spaces, because two meet here: `lines[].start/end` and
12
20
  // `runs[].start/end` are UTF-16 code units (what `rangeBands` in nodes.js
@@ -156,15 +164,199 @@ const GENERIC_FAMILIES = new Set([
156
164
  'ui-monospace',
157
165
  ]);
158
166
 
167
+ /** The size a face is probed at when the question has no size in it. */
168
+ const PROBE_SIZE = 14;
169
+
170
+ /**
171
+ * A face — what `match()` and `fallbackFor()` answer — in the shape of
172
+ * ntk's `Font` as far as a renderer that positions glyphs itself reads it
173
+ * (ntk docs/text.md#glyph-runs): `metrics`, `hasGlyph`, `glyphIdFor`,
174
+ * `advanceOf`, `shape`. The face defers size the way ntk's does: `_handle`
175
+ * is the CTFont at a concrete pixel size, and every member that needs one
176
+ * asks for it, so one face serves a terminal at 13px and a label at 24px.
177
+ *
178
+ * Glyph ids are the font's own indices in both engines, so a run built
179
+ * from `glyphIdFor` here draws through `ctx.drawGlyphs` here with no
180
+ * translation; `run.font` is simply this object.
181
+ */
182
+ export class CocoaFace {
183
+ /**
184
+ * @param manager the CocoaFontManager
185
+ * @param key stable identity (the match key, or the PostScript name of a
186
+ * face that arrived by substitution)
187
+ * @param sizedHandle (size) => CTFont handle for this face at that size
188
+ */
189
+ constructor(manager, key, sizedHandle) {
190
+ this._manager = manager;
191
+ this.key = key;
192
+ this._sizedHandle = sizedHandle;
193
+ this._sized = new Map(); // size -> handle
194
+ this._covers = new Map(); // code point -> face that covers it | null
195
+ this._names = null;
196
+ }
197
+
198
+ _handle(size) {
199
+ const s = Number.isFinite(size) && size > 0 ? size : PROBE_SIZE;
200
+ let handle = this._sized.get(s);
201
+ if (handle === undefined) {
202
+ handle = this._sizedHandle(s) ?? null;
203
+ this._sized.set(s, handle);
204
+ }
205
+ return handle;
206
+ }
207
+
208
+ _name() {
209
+ if (!this._names) {
210
+ const m = this._manager._native.fontMetrics(this._handle(PROBE_SIZE));
211
+ this._names = {
212
+ familyName: m.familyName ?? '',
213
+ postscriptName: m.postScriptName ?? '',
214
+ };
215
+ }
216
+ return this._names;
217
+ }
218
+
219
+ /** The family CoreText resolved — `Menlo`, `Apple Color Emoji`. */
220
+ get familyName() {
221
+ return this._name().familyName;
222
+ }
223
+
224
+ /** ntk's spelling of the PostScript name, for callers written against
225
+ * its `Font`. */
226
+ get postscriptName() {
227
+ return this._name().postscriptName;
228
+ }
229
+
230
+ /**
231
+ * Scaled to a pixel size. CoreText's names (`leading`) and ntk's
232
+ * (`lineGap`, `lineHeight`) side by side, so a caller written against
233
+ * either reads a finite line height.
234
+ */
235
+ metrics(size) {
236
+ const m = this._manager._native.fontMetrics(this._handle(size));
237
+ return {
238
+ ...m,
239
+ lineGap: m.leading,
240
+ lineHeight: m.ascent + m.descent + m.leading,
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Coverage. A code point (ntk's contract) or a string — the two forms
246
+ * `hasGlyph` has been asked in.
247
+ */
248
+ hasGlyph(codepoint) {
249
+ if (typeof codepoint === 'number')
250
+ return this.glyphIdFor(codepoint) !== null;
251
+ return this._manager._native.fontHasGlyph(
252
+ this._handle(PROBE_SIZE),
253
+ String(codepoint),
254
+ );
255
+ }
256
+
257
+ /**
258
+ * Glyph id for a code point, or `null` when this face does not map it —
259
+ * the lookup twin of `hasGlyph` (ntk#254). A cmap lookup only: no
260
+ * shaping, so text that needs ligatures or marks goes through `shape()`.
261
+ */
262
+ glyphIdFor(codepoint) {
263
+ if (typeof codepoint !== 'number') return null;
264
+ return this._manager._native.fontGlyphForCodepoint(
265
+ this._handle(PROBE_SIZE),
266
+ codepoint,
267
+ );
268
+ }
269
+
270
+ /** Nominal (unshaped) horizontal advance of a glyph id, in pixels at
271
+ * `size`. */
272
+ advanceOf(glyphId, size) {
273
+ const advances = this._manager._native.fontGlyphAdvances(
274
+ this._handle(size),
275
+ [glyphId],
276
+ );
277
+ return advances[0] ?? 0;
278
+ }
279
+
280
+ /**
281
+ * Shape a run of text at a pixel size: `{ font, size, direction, width,
282
+ * glyphs: [{ id, ax, dx, dy }] }`, glyphs in visual order with ntk's
283
+ * pen contract — `ax` the advance, `dx`/`dy` the drawing offset from the
284
+ * pen (y up). One CTLine through the typesetter (`fontShapeText`),
285
+ * which is what a cluster — a base with combining marks, an emoji
286
+ * sequence, a variation selector — needs and a grid renderer bypasses
287
+ * for everything else; the terminal shapes a cluster at a time and
288
+ * caches the run, so the typesetter runs once per distinct cluster.
289
+ *
290
+ * One thing ntk's `shape()` never does happens here: CoreText substitutes
291
+ * a face for characters this one lacks instead of shaping them as
292
+ * `.notdef`, and a glyph from a substituted run carries that face as
293
+ * `font` — an extra field ntk's contract ignores and this backend's
294
+ * `drawGlyphs` honours, so `☺️` in Menlo comes out as the emoji rather
295
+ * than Menlo's glyph at the emoji font's index.
296
+ */
297
+ shape(text, size, opts = {}) {
298
+ const manager = this._manager;
299
+ const raw = manager._native.fontShapeText(this._handle(size), String(text));
300
+ const glyphs = [];
301
+ let pen = 0;
302
+ for (const run of raw.runs) {
303
+ const face = run.font ? manager._faceOfHandle(run.font, size) : null;
304
+ for (let i = 0; i < run.glyphs.length; i++) {
305
+ const ax = run.advances[i];
306
+ const glyph = {
307
+ id: run.glyphs[i],
308
+ ax,
309
+ dx: run.positions[i * 2] - pen,
310
+ dy: run.positions[i * 2 + 1],
311
+ };
312
+ if (face) glyph.font = face;
313
+ glyphs.push(glyph);
314
+ pen += ax;
315
+ }
316
+ }
317
+ return {
318
+ font: this,
319
+ size,
320
+ direction: opts.direction ?? 'ltr',
321
+ width: raw.width,
322
+ glyphs,
323
+ };
324
+ }
325
+
326
+ /**
327
+ * A face that covers `codepoint` when this one does not — this face
328
+ * itself when it does, `null` when nothing on the system does. Faces the
329
+ * app loaded first, then CoreText's cascade off this face
330
+ * (`fontFallbackFor`). Cached per code point.
331
+ */
332
+ _coverFor(codepoint) {
333
+ if (this._covers.has(codepoint)) return this._covers.get(codepoint);
334
+ const found = this._manager._fallbackFrom(this, codepoint);
335
+ this._covers.set(codepoint, found);
336
+ return found;
337
+ }
338
+ }
339
+
159
340
  export class CocoaFontManager {
160
- constructor() {
161
- this._native = loadNative();
341
+ /** @param native the @windowkit/appkit module; the tests hand in a fake */
342
+ constructor(native = loadNative()) {
343
+ this._native = native;
162
344
  this._fonts = new Map(); // family|weight|italic|size -> handle
163
345
  this._faces = new Map(); // family|weight|italic -> face wrapper
164
346
  this._registered = new Map(); // lowercase family -> [{cg, weight, italic}]
165
347
  this._byKey = new Map(); // ntk Font key -> { cg } | { ps }
166
348
  this._sized = new Map(); // face key|size|variations -> CTFont handle
167
349
  this._layouts = new Map(); // layout signature -> CocoaTextLayout (LRU)
350
+ this._faceByPs = new Map(); // PostScript name -> face CoreText chose itself
351
+ }
352
+
353
+ /** A face the app loaded joins every fallback chain: what the faces that
354
+ * outlive `_faces.clear()` remembered about coverage is stale. */
355
+ _forgetCovers() {
356
+ for (const face of this._faceByPs.values()) face._covers.clear();
357
+ for (const faces of this._registered.values()) {
358
+ for (const reg of faces) reg.face?._covers.clear();
359
+ }
168
360
  }
169
361
 
170
362
  /**
@@ -211,6 +403,7 @@ export class CocoaFontManager {
211
403
  this._fonts.clear();
212
404
  this._faces.clear();
213
405
  this._sized.clear();
406
+ this._forgetCovers();
214
407
  }
215
408
  }
216
409
  } catch {
@@ -354,8 +547,10 @@ export class CocoaFontManager {
354
547
 
355
548
  /**
356
549
  * A face by family/weight/style — what `textBoxTrim` reads `capHeight`
357
- * from. The face defers size, like ntk's: `metrics(size)` answers for a
358
- * concrete pixel size.
550
+ * from and what a terminal builds its glyph runs on. The face defers
551
+ * size, like ntk's: `metrics(size)` answers for a concrete pixel size,
552
+ * and the family re-resolves per size through `_font` (SF's optical
553
+ * sizing is a different face at 13px and at 28px).
359
554
  */
360
555
  match(family, { weight, style } = {}) {
361
556
  const w = numericWeight(weight);
@@ -363,25 +558,116 @@ export class CocoaFontManager {
363
558
  const key = `${family}|${w}|${italic}`;
364
559
  let face = this._faces.get(key);
365
560
  if (!face) {
366
- const manager = this;
367
- face = {
368
- metrics(size) {
369
- return manager._native.fontMetrics(
370
- manager._font(family, w, italic, size ?? 14),
371
- );
372
- },
373
- hasGlyph(char) {
374
- return manager._native.fontHasGlyph(
375
- manager._font(family, w, italic, 14),
376
- String(char),
377
- );
378
- },
379
- };
561
+ face = new CocoaFace(this, key, (size) =>
562
+ this._font(family, w, italic, size),
563
+ );
380
564
  this._faces.set(key, face);
381
565
  }
382
566
  return face;
383
567
  }
384
568
 
569
+ /**
570
+ * A face that covers `codepoint` when the one for `family` does not, or
571
+ * `null` when nothing on the system does — ntk's
572
+ * `FontManager.fallbackFor`, in its order: faces the app loaded first (an
573
+ * app that ships a font means the one it ships), then CoreText's cascade
574
+ * off the matched face (`CTFontCreateForString`), the same substitution
575
+ * `layout()` gets from the typesetter. Cached per family, weight, style
576
+ * and code point; the matched face itself when it already covers the
577
+ * code point, so runs group with the base's.
578
+ */
579
+ fallbackFor(codepoint, family = 'sans-serif', opts = {}) {
580
+ if (typeof codepoint !== 'number') {
581
+ codepoint = String(codepoint).codePointAt(0) ?? -1;
582
+ }
583
+ return this.match(family, opts)._coverFor(codepoint);
584
+ }
585
+
586
+ /** `fallbackFor` from a face rather than a family — see `_coverFor`. */
587
+ _fallbackFrom(base, codepoint) {
588
+ let found = null;
589
+ for (const faces of this._registered.values()) {
590
+ for (const reg of faces) {
591
+ const face = this._registeredFace(reg);
592
+ if (face.glyphIdFor(codepoint) !== null) {
593
+ found = face;
594
+ break;
595
+ }
596
+ }
597
+ if (found) break;
598
+ }
599
+ if (!found) {
600
+ let text = null;
601
+ try {
602
+ text = String.fromCodePoint(codepoint);
603
+ } catch {
604
+ text = null; // not a scalar value: nothing covers it
605
+ }
606
+ if (text !== null) {
607
+ const probe = base._handle(PROBE_SIZE);
608
+ const handle = this._native.fontFallbackFor(probe, text);
609
+ // the bridge answers the probe handle itself when the face covers
610
+ // the text: no substitution needed
611
+ if (handle === probe) found = base;
612
+ else if (handle) found = this._faceOfHandle(handle, PROBE_SIZE);
613
+ }
614
+ }
615
+ if (
616
+ found &&
617
+ found !== base &&
618
+ found.postscriptName === base.postscriptName
619
+ ) {
620
+ found = base;
621
+ }
622
+ return found;
623
+ }
624
+
625
+ /** The face of a loaded file, over its own CGFont handle. */
626
+ _registeredFace(reg) {
627
+ if (!reg.face) {
628
+ reg.face = new CocoaFace(this, 'registered', (size) =>
629
+ this._native.cgFontWithSize(reg.cg, size),
630
+ );
631
+ reg.face.key = `registered:${reg.face.postscriptName}`;
632
+ }
633
+ return reg.face;
634
+ }
635
+
636
+ /**
637
+ * The face behind a CTFont handle CoreText chose itself — a fallback, or
638
+ * a substituted run inside `shape()` — one object per PostScript name, so
639
+ * runs from either route group together in `drawGlyphs`. The handle seeds
640
+ * the size it came at; other sizes are copies of it (`fontWithSize`),
641
+ * which is how a face with no family to re-match by answers
642
+ * `metrics(size)` and advances everywhere, as itself.
643
+ */
644
+ _faceOfHandle(handle, size) {
645
+ const ps = this._native.fontMetrics(handle).postScriptName;
646
+ let face = this._faceByPs.get(ps);
647
+ if (!face) {
648
+ face = new CocoaFace(this, `ps:${ps}`, (s) =>
649
+ this._native.fontWithSize(handle, s),
650
+ );
651
+ this._faceByPs.set(ps, face);
652
+ }
653
+ if (!face._sized.has(size)) face._sized.set(size, handle);
654
+ return face;
655
+ }
656
+
657
+ /**
658
+ * The CTFont a glyph run draws with (`CocoaContext2D.drawGlyphs`): a face
659
+ * of this engine's at the run's size, or an ntk `Font` — `openFont()`'s —
660
+ * resolved to CoreText from the same bytes, so the glyph ids it shaped
661
+ * with hold. Null for anything else, and the run is skipped.
662
+ */
663
+ _runHandle(font, size) {
664
+ if (font instanceof CocoaFace) return font._handle(size);
665
+ if (font && (font.postscriptName || font.path || font.fk)) {
666
+ return this._faceFont(font, size);
667
+ }
668
+ return null;
669
+ }
670
+
385
671
  /**
386
672
  * `loadFont()`'s engine half: register the face with CoreText so the
387
673
  * process can match it by family name (with real weight/italic traits —
@@ -444,6 +730,7 @@ export class CocoaFontManager {
444
730
  this._faces.clear();
445
731
  this._sized.clear();
446
732
  this._layouts.clear();
733
+ this._forgetCovers();
447
734
  // `null` on purpose: src/fonts.js keeps the fontkit face it already
448
735
  // opened as the handle, which is the one whose metrics apps can read;
449
736
  // the registry above is what rendering resolves against.
@@ -500,9 +787,11 @@ export class CocoaFontManager {
500
787
  // which is how the fonts app renders exactly the file it opened.
501
788
  const face = span.font ?? base.font;
502
789
  let handle =
503
- face && (face.postscriptName || face.path || face.fk)
504
- ? this._faceFont(face, size, variations)
505
- : null;
790
+ face instanceof CocoaFace
791
+ ? this._withVariations(face._handle(size), variations)
792
+ : face && (face.postscriptName || face.path || face.fk)
793
+ ? this._faceFont(face, size, variations)
794
+ : null;
506
795
  handle ??= this._withVariations(
507
796
  this._font(
508
797
  span.family ?? base.family,
@@ -321,7 +321,22 @@ export class CocoaWindow {
321
321
  dx,
322
322
  dy,
323
323
  );
324
- if (moved) this._dirty = true;
324
+ if (moved) {
325
+ this._dirty = true;
326
+ // The band moved inside the BACK buffer only. After the flip the
327
+ // other buffer still holds the band where it was, and the catch-up
328
+ // copy only covers what the flush painted — the strips the shift
329
+ // exposed — so the next frame would blit a band one frame stale.
330
+ // Record the shifted rect as painted, and the flip's copy carries it.
331
+ this.noteFrameDamage([
332
+ {
333
+ x: Math.round(rect.x),
334
+ y: Math.round(rect.y),
335
+ width: Math.round(rect.width),
336
+ height: Math.round(rect.height),
337
+ },
338
+ ]);
339
+ }
325
340
  return Boolean(moved);
326
341
  }
327
342
 
package/src/nodes.js CHANGED
@@ -486,6 +486,25 @@ function rectsBounds(rects) {
486
486
  }
487
487
 
488
488
  /** Do two rects share any area? Touching edges do not count. */
489
+ /** Does `rect` reach into any of the four `radius`-sized corner squares of
490
+ * `box` — the only part of a rounded border a translation cannot keep? */
491
+ function cornerSquaresOverlap(box, radius, rect) {
492
+ const r = Math.min(radius, box.width / 2, box.height / 2);
493
+ if (!(r > 0)) return false;
494
+ const corners = [
495
+ { x: box.x, y: box.y, width: r, height: r },
496
+ { x: box.x + box.width - r, y: box.y, width: r, height: r },
497
+ { x: box.x, y: box.y + box.height - r, width: r, height: r },
498
+ {
499
+ x: box.x + box.width - r,
500
+ y: box.y + box.height - r,
501
+ width: r,
502
+ height: r,
503
+ },
504
+ ];
505
+ return corners.some((square) => rectsOverlap(square, rect));
506
+ }
507
+
489
508
  function rectsOverlap(a, b) {
490
509
  return (
491
510
  a.x < b.x + b.width &&
@@ -11127,12 +11146,51 @@ export class WindowNode extends Scrollable(Node) {
11127
11146
  }
11128
11147
  const keep = this._blitKeptDamage(vp, true);
11129
11148
  if (!keep) return;
11130
- if (!this._scrollBlitSafe(node, vp)) return;
11149
+ // An ancestor's rounded corners reach into the top and bottom rows of
11150
+ // the region (a graph pane inside a rounded card is the common shape):
11151
+ // those rows do not translate, so they leave the blit and get repainted
11152
+ // as bands — the same carve the element does for its own furniture —
11153
+ // and the band that shifts is what is left between them.
11154
+ const bands = this._cornerBands(node, vp);
11155
+ const shifted =
11156
+ bands.top || bands.bottom
11157
+ ? {
11158
+ x: vp.x,
11159
+ y: vp.y + bands.top,
11160
+ width: vp.width,
11161
+ height: vp.height - bands.top - bands.bottom,
11162
+ }
11163
+ : vp;
11164
+ if (shifted.height <= 0 || Math.abs(dy) >= shifted.height) return;
11165
+ if (
11166
+ (shifted.width - Math.abs(dx)) * (shifted.height - Math.abs(dy)) <
11167
+ area * SCROLL_BLIT_MIN_KEEP
11168
+ ) {
11169
+ return;
11170
+ }
11171
+ if (!this._scrollBlitSafe(node, shifted)) return;
11131
11172
  // the element's deltas are already how far the pixels moved, the sense
11132
11173
  // scrollRegion takes (0 + x rather than x: a caller's -0 would survive
11133
11174
  // into request buffers and test comparisons)
11134
- if (!this.window.scrollRegion({ ...vp }, 0 + dx, 0 + dy)) return;
11175
+ if (!this.window.scrollRegion({ ...shifted }, 0 + dx, 0 + dy)) return;
11135
11176
  let rects = keep;
11177
+ if (bands.top) {
11178
+ rects = addDamageRect(rects, {
11179
+ x: vp.x,
11180
+ y: vp.y,
11181
+ width: vp.width,
11182
+ height: bands.top,
11183
+ });
11184
+ }
11185
+ if (bands.bottom) {
11186
+ rects = addDamageRect(rects, {
11187
+ x: vp.x,
11188
+ y: shifted.y + shifted.height,
11189
+ width: vp.width,
11190
+ height: bands.bottom,
11191
+ });
11192
+ }
11193
+ vp = shifted;
11136
11194
  // The strips the shift exposed, on the sides the pixels came from. The
11137
11195
  // horizontal one takes the full width and the vertical one takes what
11138
11196
  // is left, so a diagonal shift claims two rects that do not overlap —
@@ -11157,6 +11215,28 @@ export class WindowNode extends Scrollable(Node) {
11157
11215
  this._damage = rects;
11158
11216
  }
11159
11217
 
11218
+ /**
11219
+ * How many rows at the top and at the bottom of `vp` an ancestor's rounded
11220
+ * corners reach into — the rows an element blit has to leave behind and
11221
+ * repaint, so that what shifts stays clear of every corner square
11222
+ * (`_scrollBlitSafe`). Whole pixels, and zero when no corner reaches in.
11223
+ */
11224
+ _cornerBands(node, vp) {
11225
+ let top = 0;
11226
+ let bottom = 0;
11227
+ for (let n = node.parent; n && n !== this; n = n.parent) {
11228
+ const radius = n.style?.borderRadius ?? 0;
11229
+ if (!(radius > 0) || !n.abs) continue;
11230
+ if (!cornerSquaresOverlap(n.abs, radius, vp)) continue;
11231
+ top = Math.max(top, Math.ceil(n.abs.y + radius - vp.y));
11232
+ bottom = Math.max(
11233
+ bottom,
11234
+ Math.ceil(vp.y + vp.height - (n.abs.y + n.abs.height - radius)),
11235
+ );
11236
+ }
11237
+ return { top: Math.max(0, top), bottom: Math.max(0, bottom) };
11238
+ }
11239
+
11160
11240
  /**
11161
11241
  * May the viewport's pixels be moved wholesale? Only if every pixel in it
11162
11242
  * belongs to the scrolled content (or to a plain solid fill behind it):
@@ -11186,14 +11266,18 @@ export class WindowNode extends Scrollable(Node) {
11186
11266
  child.style ?? EMPTY_STYLE,
11187
11267
  child.direction,
11188
11268
  );
11189
- const inset = Math.max(
11190
- bw.top,
11191
- bw.right,
11192
- bw.bottom,
11193
- bw.left,
11194
- child.style?.borderRadius ?? 0,
11195
- );
11196
- if (inset > 0 && !rectContains(insetRect(child.abs, inset), vp)) {
11269
+ const ring = Math.max(bw.top, bw.right, bw.bottom, bw.left);
11270
+ if (ring > 0 && !rectContains(insetRect(child.abs, ring), vp)) {
11271
+ return false;
11272
+ }
11273
+ // A rounded corner is not a ring: the arc lives in the four
11274
+ // radius-sized squares at the corners, and the straight run of
11275
+ // the edge between them is the border ring already excluded
11276
+ // above. A viewport that reaches the edge but stays clear of the
11277
+ // squares — an element that carved the corner rows into bands it
11278
+ // repaints (`_cornerBands`) — is translation-safe.
11279
+ const radius = child.style?.borderRadius ?? 0;
11280
+ if (radius > 0 && cornerSquaresOverlap(child.abs, radius, vp)) {
11197
11281
  return false;
11198
11282
  }
11199
11283
  if (typeof child._scrollbars === 'function') {
@@ -169,7 +169,7 @@ export class TextSelection {
169
169
 
170
170
  press(ev) {
171
171
  if (ev.button !== 1) return;
172
- const at = this.positionAt(ev.x, ev.y);
172
+ const at = this.positionAt(...this.devicePoint(ev));
173
173
  if (!at) return;
174
174
  this.granularity =
175
175
  ev.detail >= 3 ? 'block' : ev.detail === 2 ? 'word' : 'char';
@@ -190,7 +190,7 @@ export class TextSelection {
190
190
 
191
191
  drag(ev) {
192
192
  if (!this.dragging) return;
193
- const at = this.positionAt(ev.x, ev.y);
193
+ const at = this.positionAt(...this.devicePoint(ev));
194
194
  if (!at) return;
195
195
  this.focus = at;
196
196
  this.apply();
@@ -221,9 +221,27 @@ export class TextSelection {
221
221
  }
222
222
  }
223
223
 
224
+ /**
225
+ * The pointer in the space `abs` and the four accessors are in — device
226
+ * pixels (docs/scale.md). A synthetic event's `x`/`y` are logical, so on
227
+ * a 2x panel they name a point half as far from the window's origin as the
228
+ * pointer is: a press landed on the wrong character and a drag stopped
229
+ * short at half its distance. The X event underneath already carries the
230
+ * device numbers; an event synthesized without one is multiplied up, the
231
+ * way every other pointer consumer in nodes.js does it.
232
+ */
233
+ devicePoint(ev) {
234
+ const scale = this.node.scale > 0 ? this.node.scale : 1;
235
+ return [
236
+ ev.nativeEvent?.x ?? ev.x * scale,
237
+ ev.nativeEvent?.y ?? ev.y * scale,
238
+ ];
239
+ }
240
+
224
241
  // --- the selection itself ----------------------------------------------
225
242
 
226
- /** The participant and index nearest a point in window coordinates. */
243
+ /** The participant and index nearest a point in window coordinates
244
+ * device pixels, the unit `abs` and `textIndexAt` speak. */
227
245
  positionAt(x, y) {
228
246
  let best = null;
229
247
  let bestScore = Infinity;