react-x11 2.3.1 → 2.5.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.5.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
  },
package/src/cocoa/app.js CHANGED
@@ -24,13 +24,14 @@ import { CocoaGlobalMenuExport } from './globalmenu.js';
24
24
  import { CocoaPaneHost } from './panehost.js';
25
25
  import { CocoaPaneWindow } from './panewindow.js';
26
26
  import { CocoaFontManager } from './fonts.js';
27
+ import { CocoaSurface } from './surface.js';
27
28
  import { CocoaWindow } from './window.js';
28
29
  import { decodeKey, modifierMask } from './keymap.js';
29
30
  import { loadNative } from './native.js';
30
31
 
31
32
  const RAF_INTERVAL_MS = 16;
32
33
 
33
- class CocoaApp {
34
+ export class CocoaApp {
34
35
  constructor(native, options = {}) {
35
36
  this._native = native;
36
37
  this.options = options;
@@ -53,7 +54,10 @@ class CocoaApp {
53
54
  process.env.REACT_X11_COCOA_PRESENTER ??
54
55
  'surface';
55
56
 
56
- this.fonts = new CocoaFontManager();
57
+ // the app's own bridge, so an app over a fake one (the tests) needs no
58
+ // real bridge on the machine — the manager's default loads it only when
59
+ // it is built standalone
60
+ this.fonts = new CocoaFontManager(native);
57
61
 
58
62
  // AppKit-rendered control bezels. Its *presence* is the capability:
59
63
  // `useSupports('nativeControls')` and the widget set's `controls:
@@ -189,6 +193,17 @@ class CocoaApp {
189
193
  return new CocoaPaneHost(this, wnd);
190
194
  }
191
195
 
196
+ /**
197
+ * The offscreen-surface seam `react-x11/ntk`'s `Surface` dispatches on:
198
+ * ntk's `Surface` contract over a CG bitmap (src/cocoa/surface.js). Its
199
+ * presence is what makes `new Surface(app, { width, height })` answer a
200
+ * surface here rather than ntk's pixmap, which needs an X connection — a
201
+ * backend without the method gets ntk's, so an X app is never asked.
202
+ */
203
+ createSurface(options) {
204
+ return new CocoaSurface(this, options);
205
+ }
206
+
192
207
  /**
193
208
  * The `useGlobalMenu` transport seam: same owner shape as the D-Bus
194
209
  * GlobalMenuExport (start/stop/update), pointed at the macOS menu bar.
@@ -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
@@ -253,16 +279,34 @@ export class CocoaContext2D {
253
279
  }
254
280
 
255
281
  save() {
282
+ // Sync before pushing: a fresh surface empties the stack on its way in,
283
+ // and a state pushed ahead of that sync was lost to it — the first
284
+ // save/restore pair on a new context restored nothing.
285
+ const surface = this._s();
256
286
  this._stack.push({ ...this._state, dash: [...this._state.dash] });
257
- this._native.ctxSave(this._s());
287
+ this._native.ctxSave(surface);
258
288
  }
259
289
 
260
290
  restore() {
291
+ // Canvas's rule: a restore with nothing saved does nothing. It is also
292
+ // what keeps a surface's base state safe under an unbalanced painter —
293
+ // the native stack below the JS one is the surface's own — and what a
294
+ // replaced backing surface wants, since it has nothing saved either.
295
+ const surface = this._s();
261
296
  const prev = this._stack.pop();
262
- if (prev) this._state = prev;
263
- this._native.ctxRestore(this._s());
297
+ if (!prev) return;
298
+ this._state = prev;
299
+ this._native.ctxRestore(surface);
264
300
  }
265
301
 
302
+ /**
303
+ * ntk's contract has a caller who took a context owing it a `destroy()`
304
+ * — there it is a GC and a Picture. Here a context is JS state over the
305
+ * surface's own graphics state, so there is nothing to free; the call is
306
+ * honoured so a caller written against ntk needs no branch.
307
+ */
308
+ destroy() {}
309
+
266
310
  _concat(a2, b2, c2, d2, e2, f2) {
267
311
  const [a, b, c, d, e, f] = this._state.ctm;
268
312
  this._state.ctm = [
@@ -617,6 +661,107 @@ export class CocoaContext2D {
617
661
  return promise;
618
662
  }
619
663
 
664
+ // --- glyph runs (ntk's documented run contract) --------------------------
665
+
666
+ /** ntk's Render extension object, as much of it as text needs. */
667
+ get Render() {
668
+ return RENDER;
669
+ }
670
+
671
+ createSolidPicture(r, g, b, a) {
672
+ return new SolidPicture(r, g, b, a);
673
+ }
674
+
675
+ /**
676
+ * Composite glyph runs — ntk's contract (its docs/text.md#glyph-runs),
677
+ * so a renderer written against ntk's context runs here unchanged:
678
+ * `positioned` is `[{ run: { font, size, glyphs: [{ id, ax, dx, dy }] },
679
+ * x, y }]`, `x`/`y` the run's baseline origin in user space, the pen
680
+ * starting at `x` and each glyph inking at `(pen + dx, y - dy)` — `dy`
681
+ * y-up — before advancing by `ax`. `op` is `Render.PictOp.Over` or
682
+ * `.Src`; `src` a `createSolidPicture` ink.
683
+ *
684
+ * The glyphs are grouped by face and size and go out as one native call
685
+ * — `CTFontDrawGlyphs` per group, with the fill set to `src`'s colour —
686
+ * so a frame of terminal text is one call per foreground colour.
687
+ * `run.font` is a face from `fonts.match()`/`fallbackFor()`, or an ntk
688
+ * `Font` from `openFont()` (resolved to CoreText from the same bytes, so
689
+ * its glyph ids hold); a glyph carrying a `font` of its own — what
690
+ * `shape()` produces when CoreText substituted a face — draws with that
691
+ * face.
692
+ *
693
+ * One difference from ntk, stated: the transform applies to the glyphs as
694
+ * well as to their origins, because CoreGraphics draws text through the
695
+ * CTM like everything else, where ntk moves the origins and keeps the
696
+ * advances in device pixels. Under a translate, which is what a node's
697
+ * paint runs in, the two agree.
698
+ */
699
+ drawGlyphs(op, src, positioned) {
700
+ if (!Array.isArray(positioned) || positioned.length === 0) return;
701
+ const fonts = this._fonts;
702
+ if (typeof fonts?._runHandle !== 'function') return;
703
+ const batches = new Map(); // CTFont handle -> { font, glyphs, positions }
704
+ for (const placed of positioned) {
705
+ const run = placed?.run;
706
+ const glyphs = run?.glyphs;
707
+ if (!glyphs?.length) continue;
708
+ const size = run.size;
709
+ const runHandle = fonts._runHandle(run.font, size);
710
+ let pen = 0;
711
+ for (const g of glyphs) {
712
+ const handle = g.font ? fonts._runHandle(g.font, size) : runHandle;
713
+ if (handle) {
714
+ let batch = batches.get(handle);
715
+ if (!batch) {
716
+ batch = { font: handle, glyphs: [], positions: [] };
717
+ batches.set(handle, batch);
718
+ }
719
+ batch.glyphs.push(g.id);
720
+ batch.positions.push(
721
+ placed.x + pen + (g.dx || 0),
722
+ placed.y - (g.dy || 0),
723
+ );
724
+ }
725
+ pen += g.ax || 0;
726
+ }
727
+ }
728
+ if (batches.size === 0) return;
729
+ const [r, g, b, a] = this._inkOf(src);
730
+ const surface = this._s();
731
+ this._native.ctxSetFillColor(surface, r, g, b, a);
732
+ const runs = [];
733
+ for (const batch of batches.values()) {
734
+ runs.push({
735
+ font: batch.font,
736
+ glyphs: Uint16Array.from(batch.glyphs),
737
+ positions: Float64Array.from(batch.positions),
738
+ });
739
+ }
740
+ this._native.ctxDrawGlyphs(surface, runs);
741
+ this._dirty();
742
+ }
743
+
744
+ /**
745
+ * The straight colour a `drawGlyphs` source paints with: a solid ink, a
746
+ * CSS colour string, a straight `[r, g, b, a]` — or, for anything else
747
+ * (a gradient, which glyph runs do not fill through here), the fill
748
+ * style in force.
749
+ */
750
+ _inkOf(src) {
751
+ if (src instanceof SolidPicture) return src._rgba;
752
+ if (typeof src === 'string') return parseColor(src);
753
+ if (Array.isArray(src) && src.length >= 3) {
754
+ return [
755
+ clamp01(src[0]),
756
+ clamp01(src[1]),
757
+ clamp01(src[2]),
758
+ src.length > 3 ? clamp01(src[3]) : 1,
759
+ ];
760
+ }
761
+ const style = this._state.fillStyle;
762
+ return style instanceof LinearGradient ? BLACK : parseColor(style);
763
+ }
764
+
620
765
  // --- text (minimal: enough for <canvas onDraw> users) --------------------
621
766
 
622
767
  _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,
@@ -0,0 +1,229 @@
1
+ // An offscreen drawing surface on the Cocoa backend — ntk's `Surface`
2
+ // contract (draw once, composite many, and shift a retained band in place)
3
+ // over one @windowkit/appkit CG bitmap. `react-x11/ntk`'s `Surface` hands
4
+ // out one of these when the app it is given is a Cocoa app (the app's
5
+ // `createSurface` seam, src/cocoa/app.js), so a component allocates its
6
+ // buffer the same way on both backends and names neither:
7
+ //
8
+ // const surface = new Surface(app, { width, height }); // device pixels
9
+ // const ctx = surface.getContext('2d'); // a CocoaContext2D
10
+ // ctx.fillRect(0, 0, width, height);
11
+ // surface.copyWithin({ x: 0, y: 0, width, height }, 0, -rowHeight);
12
+ // windowCtx.drawImage(surface, x, y); // one composite
13
+ //
14
+ // What is the same as ntk's: the constructor, `width`/`height`/`format`/
15
+ // `depth`/`bytes`, `getContext`, `render`, `clear`, `copyWithin` down to its
16
+ // clamping (ntk#252 — integer deltas, the band that survives, false when
17
+ // nothing does), `destroy`/`Symbol.dispose`, and `drawImage` taking the
18
+ // surface as a source. What differs is stated here, because this is where
19
+ // it lives:
20
+ //
21
+ // - **One graphics state per surface.** CoreGraphics keeps the CTM, the
22
+ // clip and the path in the bitmap context itself, where an X connection
23
+ // keeps them per Picture/GC. So `getContext('2d')` answers the same
24
+ // context every time — a JS object over that one state, nothing to free,
25
+ // and `destroy()` on it is a no-op — and `render()` brackets its callback
26
+ // in save/restore from the identity transform, so a one-shot draw leaves
27
+ // no residue for the next painter, which is what ntk gets from building a
28
+ // fresh context per call.
29
+ // - **`format: 'a8'` is not here yet.** Coverage surfaces are what the paint
30
+ // cache's masks and the shadows use, and both stay on their X path; every
31
+ // consumer that allocates a surface of its own asks for argb32. Asking
32
+ // for a8 throws rather than answering a colour surface that would
33
+ // composite differently.
34
+ // - **No Picture.** `picture()` is X's compositing handle; here a surface
35
+ // composites through `ctx.drawImage`, and asking for the picture says so.
36
+ // - **Freed on collection.** The bridge frees the bitmap from the handle's
37
+ // finalizer; `destroy()` drops the handle and refuses further use, so the
38
+ // memory goes with the next GC rather than on the call.
39
+ //
40
+ // Units are device pixels, like the window's backing store: a caller sizes
41
+ // one from `contentBox()` numbers, which are device pixels already
42
+ // (docs/scale.md). The bridge is told the app's scale so the bitmap carries
43
+ // it — inert for a `drawImage` source, right for a layer's contents.
44
+ import { CocoaContext2D } from './context2d.js';
45
+
46
+ export class CocoaSurface {
47
+ constructor(app, { width, height, format = 'argb32' } = {}) {
48
+ if (
49
+ !Number.isInteger(width) ||
50
+ !Number.isInteger(height) ||
51
+ width <= 0 ||
52
+ height <= 0
53
+ ) {
54
+ throw new Error('Surface: width and height must be positive integers');
55
+ }
56
+ if (format === 'a8') {
57
+ throw new Error(
58
+ "Surface: format 'a8' (a coverage surface) is not on the cocoa " +
59
+ 'backend yet — allocate argb32, which every backend has, and ' +
60
+ 'tint through fillStyle/globalAlpha; track docs/macos.md ' +
61
+ '"Custom drawing on a layer tree".',
62
+ );
63
+ }
64
+ if (format !== 'argb32') {
65
+ throw new Error(
66
+ `Surface: unknown format ${JSON.stringify(format)} (argb32 or a8)`,
67
+ );
68
+ }
69
+ this.app = app;
70
+ this.width = width;
71
+ this.height = height;
72
+ this.format = format;
73
+ this.depth = 32;
74
+ this._native = app._native;
75
+ this._fonts = app.fonts ?? null;
76
+ this._ctx = null;
77
+ this._destroyed = false;
78
+ this._surfaceHandle = this._native.createSurface(
79
+ width,
80
+ height,
81
+ app.scale ?? 1,
82
+ );
83
+ // a fresh bitmap's contents are the allocator's; a surface that is only
84
+ // partly drawn must composite nothing where nothing was drawn
85
+ this.clear();
86
+ }
87
+
88
+ /** bytes of backing storage — what a cache budgets against */
89
+ get bytes() {
90
+ return this.width * this.height * 4;
91
+ }
92
+
93
+ /** X's compositing handle, which this backend does not have. */
94
+ picture() {
95
+ throw new Error(
96
+ 'Surface: a surface on the cocoa backend has no XRender Picture — ' +
97
+ 'composite it with ctx.drawImage(surface, x, y), which takes a ' +
98
+ 'surface directly on both backends.',
99
+ );
100
+ }
101
+
102
+ _handle() {
103
+ if (this._destroyed) {
104
+ throw new Error(
105
+ 'Surface: destroyed — a context on a destroyed surface cannot ' +
106
+ 'draw; allocate a new Surface and draw into that.',
107
+ );
108
+ }
109
+ return this._surfaceHandle;
110
+ }
111
+
112
+ _context() {
113
+ if (!this._ctx) {
114
+ this._ctx = new CocoaContext2D(
115
+ this._native,
116
+ () => this._handle(),
117
+ () => 1,
118
+ );
119
+ this._ctx._fonts = this._fonts;
120
+ }
121
+ return this._ctx;
122
+ }
123
+
124
+ /**
125
+ * The 2d context on the bitmap — the same one every time, since the
126
+ * bitmap has one graphics state (see the header). ntk's contract has the
127
+ * caller owning it and owing it a `destroy()`; that call is honoured as a
128
+ * no-op, so a caller written against ntk needs no branch.
129
+ */
130
+ getContext(name = '2d') {
131
+ this._handle();
132
+ if (name !== '2d') {
133
+ throw new Error(
134
+ `Surface: getContext(${JSON.stringify(name)}) — a surface on the ` +
135
+ "cocoa backend has a '2d' context and nothing else.",
136
+ );
137
+ }
138
+ return this._context();
139
+ }
140
+
141
+ /**
142
+ * Draw into the surface through a context that starts clean — identity
143
+ * transform, the fill and line state as they were — and leaves the
144
+ * surface's state as it found it: the save/restore bracket stands in for
145
+ * the per-call context ntk builds and destroys.
146
+ */
147
+ render(fn) {
148
+ const ctx = this.getContext('2d');
149
+ ctx.save();
150
+ try {
151
+ ctx.resetTransform();
152
+ fn(ctx);
153
+ } finally {
154
+ ctx.restore();
155
+ }
156
+ return this;
157
+ }
158
+
159
+ /** Reset every pixel to fully transparent, whatever transform a live
160
+ * context holds — the clear is issued from the identity. */
161
+ clear() {
162
+ if (this._destroyed) return this;
163
+ const ctx = this._context();
164
+ ctx.save();
165
+ try {
166
+ ctx.resetTransform();
167
+ ctx.clearRect(0, 0, this.width, this.height);
168
+ } finally {
169
+ ctx.restore();
170
+ }
171
+ return this;
172
+ }
173
+
174
+ /**
175
+ * Scroll the pixels of `src` (surface coordinates, `{x, y, width,
176
+ * height}`) by (dx, dy) in place: one in-place copy of the band that
177
+ * survives the shift (the bridge's `scrollSurface`, a memmove per row), in
178
+ * place of redrawing everything that merely moved. True when the copy was
179
+ * issued; false means "nothing survives the shift here" and the caller
180
+ * repaints `src` exactly as it would have without this method.
181
+ *
182
+ * ntk#252's contract, clamp for clamp: refused when the delta is
183
+ * fractional (a sub-pixel shift changes every pixel), when it is zero,
184
+ * when nothing of `src` survives after clamping to the surface, or on a
185
+ * destroyed surface. The band is `clamped src ∩ (clamped src + delta)`,
186
+ * so nothing outside `src` is written; the overlap is safe because the
187
+ * copy walks rows in the direction that reads before it overwrites.
188
+ */
189
+ copyWithin(src, dx, dy) {
190
+ if (this._destroyed) return false;
191
+ if (!Number.isInteger(dx) || !Number.isInteger(dy)) return false;
192
+ if (dx === 0 && dy === 0) return false;
193
+ const x0 = Math.max(0, Math.floor(src.x));
194
+ const y0 = Math.max(0, Math.floor(src.y));
195
+ const x1 = Math.min(this.width, Math.ceil(src.x + src.width));
196
+ const y1 = Math.min(this.height, Math.ceil(src.y + src.height));
197
+ const dstX0 = Math.max(x0, x0 + dx);
198
+ const dstY0 = Math.max(y0, y0 + dy);
199
+ const dstX1 = Math.min(x1, x1 + dx);
200
+ const dstY1 = Math.min(y1, y1 + dy);
201
+ // written as the positive test so a NaN edge (a rect with no numbers
202
+ // in it) is a refusal too, never a native call with garbage
203
+ if (!(dstX1 > dstX0 && dstY1 > dstY0)) return false;
204
+ return Boolean(
205
+ this._native.scrollSurface(
206
+ this._surfaceHandle,
207
+ x0,
208
+ y0,
209
+ x1 - x0,
210
+ y1 - y0,
211
+ dx,
212
+ dy,
213
+ ),
214
+ );
215
+ }
216
+
217
+ destroy() {
218
+ if (this._destroyed) return;
219
+ this._destroyed = true;
220
+ this._surfaceHandle = null;
221
+ this._ctx = null;
222
+ }
223
+
224
+ [Symbol.dispose]() {
225
+ this.destroy();
226
+ }
227
+ }
228
+
229
+ export default CocoaSurface;
@@ -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') {
package/src/ntk.d.ts CHANGED
@@ -16,7 +16,13 @@
16
16
  * than a hand-written mirror that would drift out of date silently. The
17
17
  * named exports are the ones an extension actually reaches for; anything
18
18
  * else ntk has is still there at runtime.
19
+ *
20
+ * `Surface` is the exception, typed in full: it is react-x11's own class,
21
+ * answering ntk's pixmap on an X connection and a CG bitmap on the cocoa
22
+ * backend, so its shape is this package's to declare.
19
23
  */
24
+ import type { Context2D } from './node.js';
25
+
20
26
  export const createClient: (
21
27
  options?: Record<string, unknown>,
22
28
  ) => Promise<unknown>;
@@ -26,12 +32,65 @@ export const Clipboard: new (...args: unknown[]) => unknown;
26
32
  export const Path2D: new (...args: unknown[]) => unknown;
27
33
  export const Image: new (...args: unknown[]) => unknown;
28
34
  export const Pixmap: new (...args: unknown[]) => unknown;
35
+
36
+ /** What `new Surface(app, options)` takes: a size in device pixels. */
37
+ export interface SurfaceOptions {
38
+ width: number;
39
+ height: number;
40
+ /**
41
+ * `'argb32'` (the default) on every backend. `'a8'`, a coverage surface
42
+ * that composites as a mask for the fill style, is X11-only today and
43
+ * throws on the cocoa backend.
44
+ */
45
+ format?: 'argb32' | 'a8';
46
+ }
47
+
48
+ /** A rectangle in surface coordinates — what `copyWithin` shifts. */
49
+ export interface SurfaceRect {
50
+ x: number;
51
+ y: number;
52
+ width: number;
53
+ height: number;
54
+ }
55
+
29
56
  /**
30
- * Draw once, composite many — and, for an element that scrolls a retained
31
- * buffer, `copyWithin(src, dx, dy)` shifts the surviving band server-side.
32
- * See [extending.md](../docs/extending.md).
57
+ * An offscreen surface: draw once, composite many — and, for an element
58
+ * that scrolls a retained buffer, `copyWithin(src, dx, dy)` shifts the
59
+ * surviving band in place. On an X connection it is ntk's pixmap and
60
+ * Picture; on the cocoa backend a CG bitmap; the same object shape either
61
+ * way, and `ctx.drawImage(surface, …)` takes it as a source on both. See
62
+ * [extending.md](../docs/extending.md) "Scrolling the pixels, not just the
63
+ * offset".
33
64
  */
34
- export const Surface: new (...args: unknown[]) => unknown;
65
+ export interface Surface {
66
+ readonly app: unknown;
67
+ readonly width: number;
68
+ readonly height: number;
69
+ readonly format: 'argb32' | 'a8';
70
+ readonly depth: 8 | 32;
71
+ /** Bytes of backing storage — what a cache budgets against. */
72
+ readonly bytes: number;
73
+ /**
74
+ * A 2d context on the surface. The caller owns it and owes it a
75
+ * `destroy()` — real on X11 (a GC and a Picture), a no-op on cocoa, where
76
+ * a surface has one context for its whole life.
77
+ */
78
+ getContext(name: '2d', ...args: unknown[]): Context2D;
79
+ /** Draw through a context that exists for the call. */
80
+ render(fn: (ctx: Context2D) => void): this;
81
+ /** Reset every pixel to fully transparent. */
82
+ clear(): this;
83
+ /**
84
+ * Shift `src` by a whole-pixel delta in place; true when a band survived
85
+ * the shift and was copied, false when the caller should repaint `src`.
86
+ */
87
+ copyWithin(src: SurfaceRect, dx: number, dy: number): boolean;
88
+ /** X11 only — the server-side Picture, for `<image picture>`. Throws on the cocoa backend. */
89
+ picture(app?: unknown): unknown;
90
+ destroy(): void;
91
+ [Symbol.dispose](): void;
92
+ }
93
+ export const Surface: new (app: unknown, options: SurfaceOptions) => Surface;
35
94
  /** `code` values on a failed GL setup — see `<glarea onError>`. */
36
95
  export const GLXError: {
37
96
  NO_EXTENSION: 'GLX_NO_EXTENSION';
package/src/ntk.js CHANGED
@@ -21,5 +21,35 @@
21
21
  // for one — they were reachable but never declared — wants
22
22
  // `@react-x11/components` (`<Markdown>`, `<Formula>`). `SvgView` is still
23
23
  // here; a drawing is not a document.
24
+ //
25
+ // One name is not a plain re-export. `Surface` below asks the app it is
26
+ // handed for the implementation, because ntk's own is a pixmap and a
27
+ // Picture — an X connection's — and a component allocates its buffer
28
+ // without knowing which backend it was mounted on. This subpath is where a
29
+ // drawing-adjacent name gets its backend-neutral answer; the X-only names
30
+ // (`createClient`, `Pixmap`, `Picture`, `XEmbedSocket`) stay X-only.
31
+ import { Surface as NtkSurface } from 'ntk';
32
+
24
33
  export * from 'ntk';
25
34
  export { default } from 'ntk';
35
+
36
+ /**
37
+ * ntk's offscreen `Surface`, on whichever backend `app` is.
38
+ *
39
+ * An app that makes its own surfaces answers `createSurface(options)` —
40
+ * the Cocoa app does, over a CG bitmap (src/cocoa/surface.js) — and an ntk
41
+ * connection has no such method and gets ntk's pixmap. The result is
42
+ * whichever implementation answered, not an instance of this class: the
43
+ * contract is the shape — `width`/`height`, `getContext('2d')`, `render`,
44
+ * `clear`, `copyWithin`, `destroy`, and `ctx.drawImage(surface, …)` —
45
+ * (docs/extending.md "Scrolling the pixels, not just the offset"), and
46
+ * nothing needs `instanceof`.
47
+ */
48
+ export class Surface {
49
+ constructor(app, options) {
50
+ if (typeof app?.createSurface === 'function') {
51
+ return app.createSurface(options);
52
+ }
53
+ return new NtkSurface(app, options);
54
+ }
55
+ }
@@ -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;