@react-x11/components 0.4.0 → 0.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.
Files changed (219) hide show
  1. package/README.md +125 -4
  2. package/dist/charts/components.d.ts +3 -1
  3. package/dist/charts/components.d.ts.map +1 -1
  4. package/dist/charts/components.js +8 -4
  5. package/dist/charts/components.js.map +1 -1
  6. package/dist/charts/node.d.ts +13 -6
  7. package/dist/charts/node.d.ts.map +1 -1
  8. package/dist/charts/node.js +75 -24
  9. package/dist/charts/node.js.map +1 -1
  10. package/dist/charts/render.d.ts +4 -0
  11. package/dist/charts/render.d.ts.map +1 -1
  12. package/dist/charts/render.js +11 -6
  13. package/dist/charts/render.js.map +1 -1
  14. package/dist/code-editor/node.d.ts +47 -16
  15. package/dist/code-editor/node.d.ts.map +1 -1
  16. package/dist/code-editor/node.js +121 -51
  17. package/dist/code-editor/node.js.map +1 -1
  18. package/dist/flow/draw.d.ts +19 -1
  19. package/dist/flow/draw.d.ts.map +1 -1
  20. package/dist/flow/draw.js +64 -35
  21. package/dist/flow/draw.js.map +1 -1
  22. package/dist/flow/index.d.ts.map +1 -1
  23. package/dist/flow/index.js +24 -8
  24. package/dist/flow/index.js.map +1 -1
  25. package/dist/flow/model.d.ts +4 -2
  26. package/dist/flow/model.d.ts.map +1 -1
  27. package/dist/flow/model.js +4 -2
  28. package/dist/flow/model.js.map +1 -1
  29. package/dist/flow/node.d.ts +40 -9
  30. package/dist/flow/node.d.ts.map +1 -1
  31. package/dist/flow/node.js +142 -53
  32. package/dist/flow/node.js.map +1 -1
  33. package/dist/flow/types.d.ts +46 -21
  34. package/dist/flow/types.d.ts.map +1 -1
  35. package/dist/formula/index.d.ts +4 -2
  36. package/dist/formula/index.d.ts.map +1 -1
  37. package/dist/formula/index.js.map +1 -1
  38. package/dist/formula/node.d.ts +5 -1
  39. package/dist/formula/node.d.ts.map +1 -1
  40. package/dist/formula/node.js +19 -3
  41. package/dist/formula/node.js.map +1 -1
  42. package/dist/html/controls.d.ts +4 -1
  43. package/dist/html/controls.d.ts.map +1 -1
  44. package/dist/html/controls.js.map +1 -1
  45. package/dist/html/css/cascade.d.ts +11 -5
  46. package/dist/html/css/cascade.d.ts.map +1 -1
  47. package/dist/html/css/cascade.js +18 -8
  48. package/dist/html/css/cascade.js.map +1 -1
  49. package/dist/html/css/parse.d.ts +15 -10
  50. package/dist/html/css/parse.d.ts.map +1 -1
  51. package/dist/html/css/parse.js +41 -16
  52. package/dist/html/css/parse.js.map +1 -1
  53. package/dist/html/css/style.d.ts +3 -0
  54. package/dist/html/css/style.d.ts.map +1 -1
  55. package/dist/html/css/style.js +3 -1
  56. package/dist/html/css/style.js.map +1 -1
  57. package/dist/html/css/values.d.ts +14 -1
  58. package/dist/html/css/values.d.ts.map +1 -1
  59. package/dist/html/css/values.js +11 -8
  60. package/dist/html/css/values.js.map +1 -1
  61. package/dist/html/index.d.ts.map +1 -1
  62. package/dist/html/index.js +1 -0
  63. package/dist/html/index.js.map +1 -1
  64. package/dist/html/layout/boxes.d.ts +12 -5
  65. package/dist/html/layout/boxes.d.ts.map +1 -1
  66. package/dist/html/layout/boxes.js +7 -4
  67. package/dist/html/layout/boxes.js.map +1 -1
  68. package/dist/html/layout/inline.d.ts.map +1 -1
  69. package/dist/html/layout/inline.js +22 -1
  70. package/dist/html/layout/inline.js.map +1 -1
  71. package/dist/html/node.d.ts +22 -8
  72. package/dist/html/node.d.ts.map +1 -1
  73. package/dist/html/node.js +97 -15
  74. package/dist/html/node.js.map +1 -1
  75. package/dist/html/paint.d.ts +3 -0
  76. package/dist/html/paint.d.ts.map +1 -1
  77. package/dist/html/paint.js +2 -2
  78. package/dist/html/paint.js.map +1 -1
  79. package/dist/index.d.ts +7 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +21 -0
  82. package/dist/index.js.map +1 -1
  83. package/dist/maps/clip.d.ts +43 -0
  84. package/dist/maps/clip.d.ts.map +1 -0
  85. package/dist/maps/clip.js +171 -0
  86. package/dist/maps/clip.js.map +1 -0
  87. package/dist/maps/gzip.d.ts +15 -0
  88. package/dist/maps/gzip.d.ts.map +1 -0
  89. package/dist/maps/gzip.js +56 -0
  90. package/dist/maps/gzip.js.map +1 -0
  91. package/dist/maps/index.d.ts +55 -0
  92. package/dist/maps/index.d.ts.map +1 -0
  93. package/dist/maps/index.js +141 -0
  94. package/dist/maps/index.js.map +1 -0
  95. package/dist/maps/labels.d.ts +123 -0
  96. package/dist/maps/labels.d.ts.map +1 -0
  97. package/dist/maps/labels.js +323 -0
  98. package/dist/maps/labels.js.map +1 -0
  99. package/dist/maps/mvt.d.ts +244 -0
  100. package/dist/maps/mvt.d.ts.map +1 -0
  101. package/dist/maps/mvt.js +820 -0
  102. package/dist/maps/mvt.js.map +1 -0
  103. package/dist/maps/node.d.ts +278 -0
  104. package/dist/maps/node.d.ts.map +1 -0
  105. package/dist/maps/node.js +1459 -0
  106. package/dist/maps/node.js.map +1 -0
  107. package/dist/maps/overlay.d.ts +157 -0
  108. package/dist/maps/overlay.d.ts.map +1 -0
  109. package/dist/maps/overlay.js +472 -0
  110. package/dist/maps/overlay.js.map +1 -0
  111. package/dist/maps/paint.d.ts +226 -0
  112. package/dist/maps/paint.d.ts.map +1 -0
  113. package/dist/maps/paint.js +705 -0
  114. package/dist/maps/paint.js.map +1 -0
  115. package/dist/maps/proj.d.ts +281 -0
  116. package/dist/maps/proj.d.ts.map +1 -0
  117. package/dist/maps/proj.js +451 -0
  118. package/dist/maps/proj.js.map +1 -0
  119. package/dist/maps/sources.d.ts +256 -0
  120. package/dist/maps/sources.d.ts.map +1 -0
  121. package/dist/maps/sources.js +210 -0
  122. package/dist/maps/sources.js.map +1 -0
  123. package/dist/maps/style.d.ts +141 -0
  124. package/dist/maps/style.d.ts.map +1 -0
  125. package/dist/maps/style.js +147 -0
  126. package/dist/maps/style.js.map +1 -0
  127. package/dist/maps/styles.d.ts +82 -0
  128. package/dist/maps/styles.d.ts.map +1 -0
  129. package/dist/maps/styles.js +898 -0
  130. package/dist/maps/styles.js.map +1 -0
  131. package/dist/maps/tiles.d.ts +283 -0
  132. package/dist/maps/tiles.d.ts.map +1 -0
  133. package/dist/maps/tiles.js +469 -0
  134. package/dist/maps/tiles.js.map +1 -0
  135. package/dist/maps/types.d.ts +244 -0
  136. package/dist/maps/types.d.ts.map +1 -0
  137. package/dist/maps/types.js +2 -0
  138. package/dist/maps/types.js.map +1 -0
  139. package/dist/reorder/hx.d.ts +18 -0
  140. package/dist/reorder/hx.d.ts.map +1 -0
  141. package/dist/reorder/hx.js +10 -0
  142. package/dist/reorder/hx.js.map +1 -0
  143. package/dist/reorder/index.d.ts +314 -0
  144. package/dist/reorder/index.d.ts.map +1 -0
  145. package/dist/reorder/index.js +1539 -0
  146. package/dist/reorder/index.js.map +1 -0
  147. package/dist/reorder/model.d.ts +107 -0
  148. package/dist/reorder/model.d.ts.map +1 -0
  149. package/dist/reorder/model.js +162 -0
  150. package/dist/reorder/model.js.map +1 -0
  151. package/dist/richtext/node.d.ts +15 -5
  152. package/dist/richtext/node.d.ts.map +1 -1
  153. package/dist/richtext/node.js +40 -11
  154. package/dist/richtext/node.js.map +1 -1
  155. package/dist/richtext/runs.d.ts +35 -13
  156. package/dist/richtext/runs.d.ts.map +1 -1
  157. package/dist/richtext/runs.js +83 -27
  158. package/dist/richtext/runs.js.map +1 -1
  159. package/dist/tabs/index.d.ts +17 -0
  160. package/dist/tabs/index.d.ts.map +1 -1
  161. package/dist/tabs/index.js +849 -186
  162. package/dist/tabs/index.js.map +1 -1
  163. package/dist/terminal/vt/fonts.d.ts +45 -15
  164. package/dist/terminal/vt/fonts.d.ts.map +1 -1
  165. package/dist/terminal/vt/fonts.js +89 -19
  166. package/dist/terminal/vt/fonts.js.map +1 -1
  167. package/dist/terminal/vt/node.d.ts +42 -0
  168. package/dist/terminal/vt/node.d.ts.map +1 -1
  169. package/dist/terminal/vt/node.js +95 -11
  170. package/dist/terminal/vt/node.js.map +1 -1
  171. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  172. package/dist/terminal/vt/renderer.js +13 -3
  173. package/dist/terminal/vt/renderer.js.map +1 -1
  174. package/package.json +13 -3
  175. package/src/charts/components.ts +14 -7
  176. package/src/charts/node.ts +81 -26
  177. package/src/charts/render.ts +16 -6
  178. package/src/code-editor/node.ts +147 -53
  179. package/src/flow/draw.ts +104 -35
  180. package/src/flow/index.ts +33 -13
  181. package/src/flow/model.ts +4 -2
  182. package/src/flow/node.ts +158 -59
  183. package/src/flow/types.ts +46 -21
  184. package/src/formula/index.ts +4 -2
  185. package/src/formula/node.ts +24 -5
  186. package/src/html/controls.ts +4 -1
  187. package/src/html/css/cascade.ts +18 -7
  188. package/src/html/css/parse.ts +46 -21
  189. package/src/html/css/style.ts +6 -1
  190. package/src/html/css/values.ts +25 -9
  191. package/src/html/index.ts +1 -0
  192. package/src/html/layout/boxes.ts +19 -9
  193. package/src/html/layout/inline.ts +23 -1
  194. package/src/html/node.ts +110 -17
  195. package/src/html/paint.ts +17 -2
  196. package/src/index.ts +98 -0
  197. package/src/maps/clip.ts +184 -0
  198. package/src/maps/gzip.ts +66 -0
  199. package/src/maps/index.ts +316 -0
  200. package/src/maps/labels.ts +442 -0
  201. package/src/maps/mvt.ts +857 -0
  202. package/src/maps/node.ts +1819 -0
  203. package/src/maps/overlay.ts +639 -0
  204. package/src/maps/paint.ts +978 -0
  205. package/src/maps/proj.ts +641 -0
  206. package/src/maps/sources.ts +425 -0
  207. package/src/maps/style.ts +309 -0
  208. package/src/maps/styles.ts +1040 -0
  209. package/src/maps/tiles.ts +676 -0
  210. package/src/maps/types.ts +260 -0
  211. package/src/reorder/hx.ts +31 -0
  212. package/src/reorder/index.ts +2223 -0
  213. package/src/reorder/model.ts +232 -0
  214. package/src/richtext/node.ts +49 -14
  215. package/src/richtext/runs.ts +115 -30
  216. package/src/tabs/index.ts +1040 -204
  217. package/src/terminal/vt/fonts.ts +128 -36
  218. package/src/terminal/vt/node.ts +105 -12
  219. package/src/terminal/vt/renderer.ts +13 -3
@@ -11,17 +11,36 @@
11
11
  // combining marks, a text-presentation emoji with a variation selector — and
12
12
  // `Font.shape()` handles those one cluster at a time, cached, on a path a
13
13
  // normal screen never touches.
14
+ //
15
+ // Not every backend's text engine has those seams. react-x11's Cocoa backend
16
+ // up to 2.3.x answered `fonts.match()` with a face that has `metrics` and
17
+ // `hasGlyph` and nothing else (it grew the rest in 2.4.0,
18
+ // sidorares/react-x11#432), so everything here feature-detects
19
+ // (`hasGlyphRuns`), and a `FontSet` over such an engine is a valid object
20
+ // that builds no runs rather than a `TypeError` thrown from inside paint.
14
21
 
15
- /** ntk's `Font`, as this module uses it. */
16
- export interface NtkFont {
17
- metrics(size: number): {
18
- ascent: number;
19
- descent: number;
20
- lineGap: number;
21
- lineHeight: number;
22
- };
23
- advanceOf(glyphId: number, size: number): number;
22
+ /** What every backend's face answers: metrics and coverage. */
23
+ export interface EngineFont {
24
+ metrics(size: number): FontMetrics;
24
25
  hasGlyph(codepoint: number): boolean;
26
+ }
27
+
28
+ /**
29
+ * `metrics(size)` as the two engines report it. ntk gives `lineGap` and
30
+ * `lineHeight`; CoreText's name for the gap is `leading`, and it reports no
31
+ * line height at all — `lineHeightOf` reads whichever is there.
32
+ */
33
+ export interface FontMetrics {
34
+ ascent: number;
35
+ descent: number;
36
+ lineGap?: number;
37
+ lineHeight?: number;
38
+ leading?: number;
39
+ }
40
+
41
+ /** ntk's `Font`: the glyph-run seams on top of the metrics (ntk#254). */
42
+ export interface NtkFont extends EngineFont {
43
+ advanceOf(glyphId: number, size: number): number;
25
44
  /** ntk#254 — the lookup twin of `hasGlyph`, `null` when uncovered. */
26
45
  glyphIdFor(codepoint: number): number | null;
27
46
  shape(
@@ -31,17 +50,36 @@ export interface NtkFont {
31
50
  ): { glyphs: { id: number; ax: number; dx: number; dy: number }[] };
32
51
  }
33
52
 
34
- /** ntk's font manager, off `app.fonts`. */
53
+ /**
54
+ * ntk's font manager, off `app.fonts` — or another engine's, which may
55
+ * answer plainer faces and have no `fallbackFor` at all.
56
+ */
35
57
  export interface NtkFonts {
36
58
  match(
37
59
  family: string,
38
60
  opts?: { weight?: number | string; style?: string },
39
- ): NtkFont;
40
- fallbackFor(
61
+ ): EngineFont;
62
+ fallbackFor?(
41
63
  codepoint: number,
42
64
  family?: string,
43
65
  opts?: { weight?: number | string; style?: string },
44
- ): NtkFont | null;
66
+ ): EngineFont | null;
67
+ }
68
+
69
+ /**
70
+ * Whether a face can build glyph runs: `glyphIdFor` to find the glyph and
71
+ * `advanceOf` to measure it. A face without them still answers metrics and
72
+ * coverage, which is all react-x11's Cocoa backend offered before 2.4.0.
73
+ */
74
+ export function hasGlyphRuns(
75
+ font: EngineFont | null | undefined,
76
+ ): font is NtkFont {
77
+ const f = font as Partial<NtkFont> | null | undefined;
78
+ return (
79
+ !!f &&
80
+ typeof f.glyphIdFor === 'function' &&
81
+ typeof f.advanceOf === 'function'
82
+ );
45
83
  }
46
84
 
47
85
  /** One glyph run, in the shape `ctx.drawGlyphs` documents. */
@@ -77,9 +115,20 @@ export class FontSet {
77
115
  readonly family: string;
78
116
  readonly size: number;
79
117
  readonly metrics: CellMetrics;
118
+ /**
119
+ * Whether this engine's faces can build glyph runs at all.
120
+ *
121
+ * `false` when `match()` answered a face with metrics and coverage but
122
+ * none of the glyph-run seams — react-x11's Cocoa backend before 2.4.0
123
+ * (sidorares/react-x11#432). `run()` is then always null, and the node
124
+ * paints nothing rather than half a terminal. A box with no fonts at all
125
+ * is a different state and stays `true`: the engine is ntk's, it just
126
+ * matched nothing, and backgrounds still draw.
127
+ */
128
+ readonly glyphRuns: boolean;
80
129
  private _fonts: NtkFonts;
81
- private _faces = new Map<Variant, NtkFont | null>();
82
- /** `variant \0 chars` → the run to stamp, or null for "nothing to draw". */
130
+ private _faces = new Map<Variant, EngineFont | null>();
131
+ /** `variant:chars` → the run to stamp, or null for "nothing to draw". */
83
132
  private _glyphs = new Map<string, GlyphRun | null>();
84
133
 
85
134
  constructor(fonts: NtkFonts, family: string, size: number) {
@@ -88,17 +137,18 @@ export class FontSet {
88
137
  this.size = size;
89
138
 
90
139
  const regular = this.face('regular');
91
- const m = regular?.metrics(size);
92
- const ascent = m ? m.ascent : size * 0.8;
93
- const descent = m ? m.descent : size * 0.2;
94
- const lineHeight = m ? m.lineHeight : size * 1.2;
140
+ this.glyphRuns = regular === null || hasGlyphRuns(regular);
141
+ const m = safeMetrics(regular, size);
142
+ const ascent = finite(m?.ascent, size * 0.8);
143
+ const descent = finite(m?.descent, size * 0.2);
144
+ const lineHeight = m ? lineHeightOf(m, ascent, descent) : size * 1.2;
95
145
  // The advance of a digit is the cell width: in a monospace face every
96
146
  // glyph agrees, and in a face that is not monospace the digit is still
97
147
  // the least surprising answer.
98
148
  let advance = size * 0.6;
99
- if (regular) {
100
- const gid = regular.glyphIdFor(0x30);
101
- if (gid !== null) advance = regular.advanceOf(gid, size) || advance;
149
+ if (hasGlyphRuns(regular)) {
150
+ const gid = safeGlyphId(regular, 0x30);
151
+ if (gid !== null) advance = safeAdvance(regular, gid, size) || advance;
102
152
  }
103
153
  const cellWidth = Math.max(1, Math.round(advance));
104
154
  const cellHeight = Math.max(1, Math.ceil(lineHeight));
@@ -123,18 +173,19 @@ export class FontSet {
123
173
  * regular one — ntk does not synthesise either, and a fake oblique is worse
124
174
  * than an honest upright (overstrike emulation is a later, separate call).
125
175
  */
126
- face(variant: Variant): NtkFont | null {
176
+ face(variant: Variant): EngineFont | null {
127
177
  const cached = this._faces.get(variant);
128
178
  if (cached !== undefined) return cached;
129
- let font: NtkFont | null = null;
179
+ let font: EngineFont | null = null;
130
180
  try {
131
- font = this._fonts.match(this.family, {
132
- weight: variant === 'bold' || variant === 'boldItalic' ? 700 : 400,
133
- style:
134
- variant === 'italic' || variant === 'boldItalic'
135
- ? 'italic'
136
- : 'normal',
137
- });
181
+ font =
182
+ this._fonts.match(this.family, {
183
+ weight: variant === 'bold' || variant === 'boldItalic' ? 700 : 400,
184
+ style:
185
+ variant === 'italic' || variant === 'boldItalic'
186
+ ? 'italic'
187
+ : 'normal',
188
+ }) ?? null;
138
189
  } catch {
139
190
  // no fontconfig, no fonts on the box: the caller draws backgrounds and
140
191
  // no glyphs rather than failing to paint at all
@@ -152,8 +203,10 @@ export class FontSet {
152
203
  * lookups rather than cmap searches.
153
204
  */
154
205
  run(chars: string, variant: Variant): GlyphRun | null {
155
- if (!chars || chars === ' ') return null;
156
- const key = `${variant}\u0000${chars}`;
206
+ if (!this.glyphRuns || !chars || chars === ' ') return null;
207
+ // The variant never contains a colon, so the key cannot be ambiguous
208
+ // whatever the cell holds.
209
+ const key = `${variant}:${chars}`;
157
210
  const hit = this._glyphs.get(key);
158
211
  if (hit !== undefined) return hit;
159
212
  const run = this._build(chars, variant);
@@ -163,7 +216,7 @@ export class FontSet {
163
216
 
164
217
  private _build(chars: string, variant: Variant): GlyphRun | null {
165
218
  const font = this.face(variant);
166
- if (!font) return null;
219
+ if (!hasGlyphRuns(font)) return null;
167
220
  const first = chars.codePointAt(0);
168
221
  if (first === undefined) return null;
169
222
  const single = String.fromCodePoint(first).length === chars.length;
@@ -192,6 +245,7 @@ export class FontSet {
192
245
  // A cluster: shape it once and keep the result. Rare enough that the
193
246
  // shaping cost never reaches a frame budget, and correct where a
194
247
  // codepoint-at-a-time walk would stack the marks on top of each other.
248
+ if (typeof (font as Partial<NtkFont>).shape !== 'function') return null;
195
249
  try {
196
250
  const shaped = font.shape(chars, this.size);
197
251
  if (!shaped.glyphs.length) return null;
@@ -202,14 +256,17 @@ export class FontSet {
202
256
  }
203
257
 
204
258
  private _fallback(codepoint: number, variant: Variant): NtkFont | null {
259
+ const fonts = this._fonts;
260
+ if (typeof fonts.fallbackFor !== 'function') return null;
205
261
  try {
206
- return this._fonts.fallbackFor(codepoint, this.family, {
262
+ const face = fonts.fallbackFor(codepoint, this.family, {
207
263
  weight: variant === 'bold' || variant === 'boldItalic' ? 700 : 400,
208
264
  style:
209
265
  variant === 'italic' || variant === 'boldItalic'
210
266
  ? 'italic'
211
267
  : 'normal',
212
268
  });
269
+ return hasGlyphRuns(face) ? face : null;
213
270
  } catch {
214
271
  return null;
215
272
  }
@@ -218,12 +275,47 @@ export class FontSet {
218
275
 
219
276
  function safeGlyphId(font: NtkFont, codepoint: number): number | null {
220
277
  try {
221
- return font.glyphIdFor(codepoint);
278
+ const gid = font.glyphIdFor(codepoint);
279
+ return typeof gid === 'number' && Number.isInteger(gid) ? gid : null;
280
+ } catch {
281
+ return null;
282
+ }
283
+ }
284
+
285
+ function safeAdvance(font: NtkFont, glyphId: number, size: number): number {
286
+ try {
287
+ return finite(font.advanceOf(glyphId, size), 0);
288
+ } catch {
289
+ return 0;
290
+ }
291
+ }
292
+
293
+ function safeMetrics(
294
+ font: EngineFont | null,
295
+ size: number,
296
+ ): FontMetrics | null {
297
+ if (!font || typeof font.metrics !== 'function') return null;
298
+ try {
299
+ const m = font.metrics(size);
300
+ return m && typeof m === 'object' ? m : null;
222
301
  } catch {
223
302
  return null;
224
303
  }
225
304
  }
226
305
 
306
+ /**
307
+ * ntk's `lineHeight` when it is there; otherwise ascent + descent + gap, with
308
+ * the gap under either engine's name.
309
+ */
310
+ function lineHeightOf(m: FontMetrics, ascent: number, descent: number): number {
311
+ const gap = finite(m.lineGap, finite(m.leading, 0));
312
+ return finite(m.lineHeight, ascent + descent + gap);
313
+ }
314
+
315
+ function finite(n: number | undefined, fallback: number): number {
316
+ return typeof n === 'number' && Number.isFinite(n) ? n : fallback;
317
+ }
318
+
227
319
  /** `VARIANT_*` bits from `./colors.ts` to one of the four faces. */
228
320
  export function variantOf(bold: boolean, italic: boolean): Variant {
229
321
  if (bold && italic) return 'boldItalic';
@@ -68,6 +68,31 @@ const PREFERRED_COLS = 80;
68
68
  const PREFERRED_ROWS = 24;
69
69
  /** Wheel notches to lines, the way every terminal counts them. */
70
70
  const WHEEL_LINES = 3;
71
+
72
+ /**
73
+ * Said once per process: the engine behind `app.fonts` has no glyph-run
74
+ * seams, so this element paints nothing. `process` and `console` come off
75
+ * `globalThis` because `src/` compiles with `types: []` — see `warnVtOnly`
76
+ * in `../index.ts`.
77
+ */
78
+ let warnedNoGlyphRuns = false;
79
+ function warnNoGlyphRuns(): void {
80
+ if (warnedNoGlyphRuns) return;
81
+ warnedNoGlyphRuns = true;
82
+ const g = globalThis as {
83
+ process?: { env?: Record<string, string | undefined> };
84
+ console?: { warn(message: string): void };
85
+ };
86
+ if (g.process?.env?.NODE_ENV === 'production') return;
87
+ g.console?.warn(
88
+ '@react-x11/components: <Terminal backend="vt"> — the text engine behind ' +
89
+ 'app.fonts has no glyph-run seams (glyphIdFor/advanceOf on a face, ' +
90
+ 'drawGlyphs on the context), so the terminal paints nothing. ' +
91
+ "react-x11's Cocoa backend before 2.4.0 was the known case " +
92
+ '(sidorares/react-x11#432); every backend of react-x11 2.5.0 and ' +
93
+ 'later has them, so check which engine app.fonts is.',
94
+ );
95
+ }
71
96
  /** OSC 52 payloads bigger than this are a program misbehaving, not a copy. */
72
97
  const MAX_OSC52 = 1_000_000;
73
98
  /** What counts as part of a word for double-click selection. */
@@ -283,6 +308,42 @@ export class VtTermNode extends Node {
283
308
 
284
309
  // --- metrics and layout --------------------------------------------------
285
310
 
311
+ /**
312
+ * Device pixels per logical pixel — the display scale (core's
313
+ * `docs/scale.md`).
314
+ *
315
+ * Everything this node computes with is device pixels: `contentBox()`,
316
+ * `this.style.fontSize` (core multiplies a style length before handing it
317
+ * over) and so the cell metrics shaped from it, the paint context and the
318
+ * renderer's surface. Two things are not, and each is converted once where
319
+ * it enters: a synthetic event's `x`/`y` (`_devicePoint`), and the
320
+ * constants below that never pass through a style — `DEFAULT_SIZE` and the
321
+ * cell guesses `measureContent` makes before there is a font.
322
+ */
323
+ private get _scale(): number {
324
+ return this.scale > 0 ? this.scale : 1;
325
+ }
326
+
327
+ /**
328
+ * A pointer event in the grid's own pixels.
329
+ *
330
+ * The native X event carries the device position the synthetic `x`/`y`
331
+ * were divided from, so it is the exact answer where there is one — core's
332
+ * own drawn elements read it the same way — and `x * scale` is the answer
333
+ * for an event synthesized without one.
334
+ */
335
+ private _devicePoint(ev: { x: number; y: number; nativeEvent?: unknown }): {
336
+ x: number;
337
+ y: number;
338
+ } {
339
+ const native = ev.nativeEvent as { x?: unknown; y?: unknown } | null;
340
+ const s = this._scale;
341
+ return {
342
+ x: typeof native?.x === 'number' ? native.x : ev.x * s,
343
+ y: typeof native?.y === 'number' ? native.y : ev.y * s,
344
+ };
345
+ }
346
+
286
347
  /**
287
348
  * The font, off the **style** rather than off a prop of its own.
288
349
  *
@@ -291,25 +352,47 @@ export class VtTermNode extends Node {
291
352
  * reason: a terminal inheriting the font from the tree above it is what
292
353
  * makes `<box style={{ fontSize: 15 }}>` mean what it says. `<Terminal>`'s
293
354
  * `fontFamily`/`fontSize` props are folded into this element's style.
355
+ *
356
+ * The size answered is in device pixels — the unit the glyphs are shaped
357
+ * at. A `fontSize` from the style arrives already multiplied; the default
358
+ * is a logical 13 and is multiplied here, so a bare `<Terminal>` on a 2x
359
+ * panel is the size it is on a 1x one, sharper rather than smaller.
294
360
  */
295
361
  private _fontStyle(): { family: string; size: number } {
296
362
  const style = this.style as { fontFamily?: string; fontSize?: number };
297
363
  return {
298
364
  family: style.fontFamily ?? DEFAULT_FAMILY,
299
- size: Number(style.fontSize ?? DEFAULT_SIZE),
365
+ size: Number(style.fontSize ?? DEFAULT_SIZE * this._scale),
300
366
  };
301
367
  }
302
368
 
369
+ /**
370
+ * The faces for the current family and size, or null when there is no
371
+ * engine to build them from.
372
+ *
373
+ * Null is cached per font key when the engine answered but cannot build
374
+ * glyph runs — an `app.fonts` that is not ntk's and has not grown its
375
+ * seams, which react-x11's Cocoa backend was before 2.4.0
376
+ * (sidorares/react-x11#432) — so the probe runs once, not on every
377
+ * measure and paint. A box with no `app.fonts` at all (the mock
378
+ * backend) is not cached: it never had an engine to ask.
379
+ */
303
380
  private _fontSet(): FontSet | null {
304
381
  const { family, size } = this._fontStyle();
305
382
  const key = `${family}|${size}`;
306
- if (this._fonts && key === this._fontKey) return this._fonts;
383
+ if (key === this._fontKey) return this._fonts;
307
384
  const fonts = (this.app as { fonts?: NtkFonts } | null | undefined)?.fonts;
308
385
  if (!fonts) return null;
386
+ const set = new FontSet(fonts, family, size);
309
387
  this._fontKey = key;
310
- this._fonts = new FontSet(fonts, family, size);
311
388
  this._mirror.invalidate();
312
- return this._fonts;
389
+ if (!set.glyphRuns) {
390
+ warnNoGlyphRuns();
391
+ this._fonts = null;
392
+ return null;
393
+ }
394
+ this._fonts = set;
395
+ return set;
313
396
  }
314
397
 
315
398
  /**
@@ -320,8 +403,11 @@ export class VtTermNode extends Node {
320
403
  */
321
404
  override measureContent({ width, height }: MeasureConstraints): MeasuredSize {
322
405
  const fonts = this._fontSet();
323
- const cellWidth = fonts?.metrics.cellWidth ?? DEFAULT_SIZE * 0.6;
324
- const cellHeight = fonts?.metrics.cellHeight ?? DEFAULT_SIZE * 1.2;
406
+ // The constraints and the answer are device pixels, like the metrics;
407
+ // the guesses made without a font (the mock backend) are scaled to match.
408
+ const s = this._scale;
409
+ const cellWidth = fonts?.metrics.cellWidth ?? DEFAULT_SIZE * 0.6 * s;
410
+ const cellHeight = fonts?.metrics.cellHeight ?? DEFAULT_SIZE * 1.2 * s;
325
411
  return {
326
412
  width: Math.min(PREFERRED_COLS * cellWidth, width),
327
413
  height: Math.min(PREFERRED_ROWS * cellHeight, height),
@@ -763,13 +849,18 @@ export class VtTermNode extends Node {
763
849
 
764
850
  // --- input: pointer ------------------------------------------------------
765
851
 
766
- private _cellAt(ev: { x: number; y: number }): Grid {
852
+ /** The cell under a pointer event. `contentBox()` and the cell metrics are
853
+ * device pixels, so the event is read in the same unit — a synthetic
854
+ * `x`/`y` is logical, and comparing it directly put a 2x click on the
855
+ * cell half as far from the corner as the pointer. */
856
+ private _cellAt(ev: { x: number; y: number; nativeEvent?: unknown }): Grid {
767
857
  const fonts = this._fontSet();
768
858
  const box = this.contentBox();
769
859
  const cellWidth = fonts?.metrics.cellWidth ?? 1;
770
860
  const cellHeight = fonts?.metrics.cellHeight ?? 1;
771
- const col = Math.floor((ev.x - box.x) / cellWidth);
772
- const row = Math.floor((ev.y - box.y) / cellHeight);
861
+ const p = this._devicePoint(ev);
862
+ const col = Math.floor((p.x - box.x) / cellWidth);
863
+ const row = Math.floor((p.y - box.y) / cellHeight);
773
864
  return {
774
865
  col: Math.max(0, Math.min(this._cols - 1, col)),
775
866
  row: Math.max(0, Math.min(this._rows - 1, row)),
@@ -875,10 +966,12 @@ export class VtTermNode extends Node {
875
966
  }
876
967
  return;
877
968
  }
878
- // Dragging past an edge scrolls the scrollback under the pointer.
969
+ // Dragging past an edge scrolls the scrollback under the pointer. The
970
+ // box is device pixels; so is the point compared with it.
879
971
  const box = this.contentBox();
880
- if (ev.y < box.y) term.scrollLines(-1);
881
- else if (ev.y > box.y + box.height) term.scrollLines(1);
972
+ const { y } = this._devicePoint(ev);
973
+ if (y < box.y) term.scrollLines(-1);
974
+ else if (y > box.y + box.height) term.scrollLines(1);
882
975
  this._head = { line: this._absLine(row), col };
883
976
  this._updateSelection();
884
977
  }
@@ -425,15 +425,25 @@ export class RetainedRenderer extends BatchingRenderer {
425
425
  const metrics = this._metrics;
426
426
  if (!surface || !metrics || count <= 0 || srcRow === dstRow) return false;
427
427
  const h = metrics.cellHeight;
428
+ const delta = dstRow - srcRow;
429
+ // `copyWithin(rect, dx, dy)` is `scrollRegion` for a surface: it shifts
430
+ // the pixels *within* `rect` and writes nothing outside it — the band
431
+ // that survives is `rect ∩ (rect + delta)`. So the rect is the union of
432
+ // the source rows and the destination rows, not the source alone. Handed
433
+ // only the source, a scroll up by one moved rows 2… into rows 1… and
434
+ // never wrote row 0, while the mirror — shifted as a block — believed
435
+ // the old row 1 had landed there, so the top line stayed stale after
436
+ // every scroll (issue #60). With the union, the surviving band is exactly
437
+ // `dstRow…dstRow+count`, sourced from exactly `srcRow…srcRow+count`.
428
438
  const ok = surface.copyWithin(
429
439
  {
430
440
  x: 0,
431
- y: srcRow * h,
441
+ y: Math.min(srcRow, dstRow) * h,
432
442
  width: this._cols * metrics.cellWidth,
433
- height: count * h,
443
+ height: (count + Math.abs(delta)) * h,
434
444
  },
435
445
  0,
436
- (dstRow - srcRow) * h,
446
+ delta * h,
437
447
  );
438
448
  if (ok) this.count('copies');
439
449
  return ok;