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 +2 -2
- package/src/cocoa/app.js +17 -2
- package/src/cocoa/context2d.js +148 -3
- package/src/cocoa/fonts.js +311 -22
- package/src/cocoa/surface.js +229 -0
- package/src/cocoa/window.js +16 -1
- package/src/nodes.js +94 -10
- package/src/ntk.d.ts +63 -4
- package/src/ntk.js +30 -0
- package/src/textselection.js +21 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.
|
|
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.
|
|
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
|
-
|
|
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.
|
package/src/cocoa/context2d.js
CHANGED
|
@@ -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(
|
|
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)
|
|
263
|
-
this.
|
|
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) {
|
package/src/cocoa/fonts.js
CHANGED
|
@@ -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 }) ->
|
|
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
|
-
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
504
|
-
? this.
|
|
505
|
-
:
|
|
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;
|
package/src/cocoa/window.js
CHANGED
|
@@ -321,7 +321,22 @@ export class CocoaWindow {
|
|
|
321
321
|
dx,
|
|
322
322
|
dy,
|
|
323
323
|
);
|
|
324
|
-
if (moved)
|
|
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
|
-
|
|
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({ ...
|
|
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
|
|
11190
|
-
|
|
11191
|
-
|
|
11192
|
-
|
|
11193
|
-
|
|
11194
|
-
|
|
11195
|
-
|
|
11196
|
-
|
|
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
|
-
*
|
|
31
|
-
* buffer, `copyWithin(src, dx, dy)` shifts the
|
|
32
|
-
*
|
|
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
|
|
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
|
+
}
|
package/src/textselection.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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;
|