@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
@@ -0,0 +1,232 @@
1
+ // The insertion arithmetic under `<ReorderList>`: which slot a pointer is
2
+ // over, and what a move to that slot does to an order. Pure — no React, no
3
+ // display — so every case is asserted on no server (`test/reorder-model.test.ts`),
4
+ // and so a second consumer (a reorderable `<Table>`, say) can take it
5
+ // without taking the component. That promotion is the reason this is its
6
+ // own module rather than a section of `index.ts`.
7
+ //
8
+ // The model is pragmatic-drag-and-drop's **closest edge**: the item nearest
9
+ // the pointer, and which half of it — along the list's axis — the pointer is
10
+ // in. It needs nothing but the items' own rectangles, so it is the same rule
11
+ // for a vertical list, a horizontal strip, an RTL one and a wrapping grid,
12
+ // and it never has to know the gap between two items to answer for a point
13
+ // in it (`docs/prd-reorder.md`, "Geometry: the closest edge").
14
+
15
+ export type ReorderId = string | number;
16
+ export type ReorderOrientation = 'vertical' | 'horizontal';
17
+ /** Which edge of an item the pointer is nearest — in reading order, so
18
+ * `'before'` is the top of a vertical list and the *right* edge of an RTL
19
+ * strip. */
20
+ export type ReorderEdge = 'before' | 'after';
21
+
22
+ /** A rectangle in any one consistent space — the component hands over
23
+ * logical pixels, and the tests hand over whatever they like. */
24
+ export interface ReorderRect {
25
+ x: number;
26
+ y: number;
27
+ width: number;
28
+ height: number;
29
+ }
30
+
31
+ export interface ReorderPoint {
32
+ x: number;
33
+ y: number;
34
+ }
35
+
36
+ /** Where a pointer would insert: the slot (`0` … `n`, a gap between items),
37
+ * and the item and edge that slot was read off, for whoever draws it. */
38
+ export interface ReorderSlot {
39
+ slot: number;
40
+ index: number;
41
+ edge: ReorderEdge;
42
+ /**
43
+ * The pointer is inside the item's middle band and combining is on, so a
44
+ * drop merges *into* `index` rather than landing beside it. Always false
45
+ * when the caller passes no band, which is what keeps a list that does not
46
+ * combine reading exactly as it did before.
47
+ */
48
+ combine: boolean;
49
+ }
50
+
51
+ /** Squared distance from a point to a rectangle; zero inside it. */
52
+ function distance2(rect: ReorderRect, p: ReorderPoint): number {
53
+ const dx = Math.max(rect.x - p.x, 0, p.x - (rect.x + rect.width));
54
+ const dy = Math.max(rect.y - p.y, 0, p.y - (rect.y + rect.height));
55
+ return dx * dx + dy * dy;
56
+ }
57
+
58
+ /**
59
+ * The slot a pointer is over, among items laid out in `rects`' order.
60
+ *
61
+ * The nearest item wins — the one under the pointer when there is one, and
62
+ * on a tie the earlier — and its midpoint along the axis decides the edge.
63
+ * `null` only when there are no items, where the caller's answer is slot 0.
64
+ *
65
+ * `combineBand` is the fraction of the item's length at *each* end that
66
+ * still reads as an edge: 0 (the default) means every point picks an edge,
67
+ * and 0.25 means the middle half of an item reads as "merge into this one"
68
+ * — but only for a point genuinely inside it, since a point in the gap
69
+ * between two items is not over either of them.
70
+ */
71
+ export function closestSlot(
72
+ rects: readonly ReorderRect[],
73
+ point: ReorderPoint,
74
+ orientation: ReorderOrientation,
75
+ direction: 'ltr' | 'rtl' = 'ltr',
76
+ combineBand = 0,
77
+ ): ReorderSlot | null {
78
+ if (rects.length === 0) return null;
79
+ let index = 0;
80
+ let best = Infinity;
81
+ for (let i = 0; i < rects.length; i++) {
82
+ const d = distance2(rects[i]!, point);
83
+ if (d < best) {
84
+ best = d;
85
+ index = i;
86
+ }
87
+ }
88
+ const rect = rects[index]!;
89
+ const vertical = orientation === 'vertical';
90
+ const start = vertical ? rect.y : rect.x;
91
+ const length = vertical ? rect.height : rect.width;
92
+ const along = vertical ? point.y : point.x;
93
+ let before = along < start + length / 2;
94
+ // the start edge of an RTL strip is on the right
95
+ if (!vertical && direction === 'rtl') before = !before;
96
+ const edge: ReorderEdge = before ? 'before' : 'after';
97
+ // `best === 0` is the point being inside the rectangle: a band is about
98
+ // the item under the pointer, and a gap belongs to neither of its
99
+ // neighbours' middles.
100
+ const fraction = length > 0 ? (along - start) / length : 0.5;
101
+ const combine =
102
+ combineBand > 0 &&
103
+ best === 0 &&
104
+ fraction >= combineBand &&
105
+ fraction <= 1 - combineBand;
106
+ return { slot: before ? index : index + 1, index, edge, combine };
107
+ }
108
+
109
+ /**
110
+ * Where the indicator goes for a slot: the **one** place that names it.
111
+ *
112
+ * `closestSlot` answers with the item the pointer is nearest and the half of
113
+ * it the pointer is in, and two different answers can mean the same gap —
114
+ * the lower half of item 2 and the upper half of item 3 are both "between
115
+ * them". Drawing the mark from that answer puts the line in two places for
116
+ * one insertion point, which reads as two, and flickers between them as the
117
+ * pointer crosses. So the mark is derived from the slot instead: the start
118
+ * edge of the item that would follow it, or the end edge of the last item
119
+ * for the slot past the end.
120
+ */
121
+ export function slotMark(
122
+ count: number,
123
+ slot: number,
124
+ ): { index: number; edge: ReorderEdge } | null {
125
+ if (count <= 0) return null;
126
+ const at = Math.min(Math.max(slot, 0), count);
127
+ return at < count
128
+ ? { index: at, edge: 'before' }
129
+ : { index: count - 1, edge: 'after' };
130
+ }
131
+
132
+ /**
133
+ * `list` with the item at `from` moved to `to` — a new array, always, so it
134
+ * can be handed straight to a state setter. Indices past either end clamp.
135
+ * dnd-kit's helper, by its name, because it is what every `onReorder`
136
+ * handler ends up calling.
137
+ */
138
+ export function arrayMove<T>(
139
+ list: readonly T[],
140
+ from: number,
141
+ to: number,
142
+ ): T[] {
143
+ const out = list.slice();
144
+ if (out.length === 0) return out;
145
+ const a = Math.min(Math.max(from, 0), out.length - 1);
146
+ const b = Math.min(Math.max(to, 0), out.length - 1);
147
+ if (a === b) return out;
148
+ const [item] = out.splice(a, 1);
149
+ out.splice(b, 0, item as T);
150
+ return out;
151
+ }
152
+
153
+ /** Whether dropping the item at `from` into `slot` would change nothing:
154
+ * the gap before it, and the gap after it, are both where it already is. */
155
+ export function isNoopSlot(from: number, slot: number): boolean {
156
+ return from >= 0 && (slot === from || slot === from + 1);
157
+ }
158
+
159
+ /**
160
+ * What a drop of `id` into `slot` does to `order`: the new order and the
161
+ * two indices, or `null` when nothing would move — the item is not in the
162
+ * list, or the slot is one of its own two gaps. `to` is where the item
163
+ * ends up *after* it has been taken out, which is what `arrayMove` wants
164
+ * and what a caller holding objects splices with.
165
+ */
166
+ export function moveToSlot(
167
+ order: readonly ReorderId[],
168
+ id: ReorderId,
169
+ slot: number,
170
+ ): { items: ReorderId[]; from: number; to: number } | null {
171
+ const many = moveManyToSlot(order, [id], slot, id);
172
+ return many && { items: many.items, from: many.from, to: many.to };
173
+ }
174
+
175
+ /**
176
+ * The same move with a **set** of items — a multi-drag. The set travels in
177
+ * the list's own order, whatever order it was selected in, and lands as one
178
+ * run at `slot`; `from`/`to` are where `primary` (the item actually under
179
+ * the pointer) started and ended up.
180
+ *
181
+ * The slot counts gaps in the *original* order, so the arithmetic has to
182
+ * discount the members of the set that were before it — which is also why
183
+ * the single-item no-op rule generalizes rather than being special-cased:
184
+ * a set that lands where it already was produces the order it started from,
185
+ * and that is what `null` reports.
186
+ */
187
+ export function moveManyToSlot(
188
+ order: readonly ReorderId[],
189
+ ids: readonly ReorderId[],
190
+ slot: number,
191
+ primary: ReorderId = ids[0]!,
192
+ ): { items: ReorderId[]; ids: ReorderId[]; from: number; to: number } | null {
193
+ const taking = new Set(ids);
194
+ // the set in the list's order, and only the members the list actually has
195
+ const moving = order.filter((id) => taking.has(id));
196
+ if (moving.length === 0) return null;
197
+ const from = order.indexOf(primary);
198
+ if (from < 0) return null;
199
+ const rest = order.filter((id) => !taking.has(id));
200
+ const before = order
201
+ .slice(0, Math.max(slot, 0))
202
+ .filter((id) => taking.has(id)).length;
203
+ const at = Math.min(Math.max(slot - before, 0), rest.length);
204
+ const items = [...rest.slice(0, at), ...moving, ...rest.slice(at)];
205
+ if (
206
+ items.length === order.length &&
207
+ items.every((id, i) => id === order[i])
208
+ ) {
209
+ return null;
210
+ }
211
+ return { items, ids: moving, from, to: items.indexOf(primary) };
212
+ }
213
+
214
+ /** `order` with `id` inserted at `slot` — an item arriving from elsewhere. */
215
+ export function insertAtSlot(
216
+ order: readonly ReorderId[],
217
+ id: ReorderId,
218
+ slot: number,
219
+ ): ReorderId[] {
220
+ return insertManyAtSlot(order, [id], slot);
221
+ }
222
+
223
+ /** The same, with a set arriving together — a multi-drag from another list. */
224
+ export function insertManyAtSlot(
225
+ order: readonly ReorderId[],
226
+ ids: readonly ReorderId[],
227
+ slot: number,
228
+ ): ReorderId[] {
229
+ const out = order.slice();
230
+ out.splice(Math.min(Math.max(slot, 0), out.length), 0, ...ids);
231
+ return out;
232
+ }
@@ -131,8 +131,10 @@ interface LaidRunLike {
131
131
  /** Extent within the paragraph, in **code units** (ntk's run vocabulary). */
132
132
  start: number;
133
133
  end: number;
134
- span: TextRun;
135
- run: {
134
+ /** Optional for the same reason as `runs.ts`'s `LaidRun`: react-x11's
135
+ * Cocoa engine hands back a run's geometry and nothing else. */
136
+ span?: TextRun;
137
+ run?: {
136
138
  font: { metrics(size: number): FontMetricsLike };
137
139
  size: number;
138
140
  direction?: 'ltr' | 'rtl';
@@ -252,9 +254,10 @@ export class RichTextNode extends Node {
252
254
  const fonts = (this.app as { fonts?: FontsLike } | null)?.fonts;
253
255
  let layout: TextLayoutLike | null = null;
254
256
  if (fonts) {
257
+ const s = this._scale;
255
258
  layout = fonts.layout(
256
- this._runs(),
257
- { family: 'sans-serif', size: 14 },
259
+ this._deviceRuns(s),
260
+ { family: 'sans-serif', size: 14 * s },
258
261
  {
259
262
  maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,
260
263
  lineHeight: this.style.lineHeight,
@@ -267,6 +270,34 @@ export class RichTextNode extends Node {
267
270
  return layout;
268
271
  }
269
272
 
273
+ // --- units ---------------------------------------------------------------
274
+ //
275
+ // The layout, `abs` and the paint are device pixels (react-x11's
276
+ // docs/scale.md). A `TextRun.size` is a length the application wrote, so
277
+ // like every style length it is logical, and unlike a style length nothing
278
+ // in core multiplies it on the way in — `_deviceRuns` does, so the runs are
279
+ // shaped at `size * scale` and a 14 on a 2x panel is the size a
280
+ // `fontSize: 14` is, sharper rather than smaller. A synthetic event's
281
+ // `x`/`y` are logical too, which is what `hrefAtPoint` is handed; the four
282
+ // selection accessors are core's device-pixel contract and stay device.
283
+
284
+ /** Device pixels per logical pixel — the display scale this element's
285
+ * window resolved to, constant for the node's life. */
286
+ private get _scale(): number {
287
+ return this.scale > 0 ? this.scale : 1;
288
+ }
289
+
290
+ /** The runs with their sizes on the device grid. The array identity is
291
+ * what the layout cache keys on, so at 1x the props' own array is
292
+ * returned untouched. */
293
+ private _deviceRuns(scale: number): TextRun[] {
294
+ const runs = this._runs();
295
+ if (scale === 1) return runs;
296
+ return runs.map((r) =>
297
+ typeof r.size === 'number' ? { ...r, size: r.size * scale } : r,
298
+ );
299
+ }
300
+
270
301
  /**
271
302
  * The layout this node is currently painted with — and, because it is the
272
303
  * one thing every accessor below answers from, the reason a caret rect and
@@ -302,8 +333,9 @@ export class RichTextNode extends Node {
302
333
  // The four accessors from react-x11#291. Implementing them is the whole of
303
334
  // joining a `selectable` document: core walks the subtree, asks, and pushes
304
335
  // back a `selectionRange` for `paint` to fill. Indices are **code points**
305
- // and rectangles are in the **owning window's** coordinates, which is the
306
- // space `abs` and a mouse event's `x`/`y` are already in.
336
+ // and rectangles are in the **owning window's** coordinates in **device**
337
+ // pixels — the space `abs` is in, and the one core asks in (it reads the
338
+ // point back off the native event; a synthetic `x`/`y` is logical).
307
339
 
308
340
  override textContent(): string {
309
341
  if (this._text === null) {
@@ -344,18 +376,20 @@ export class RichTextNode extends Node {
344
376
  }));
345
377
  }
346
378
 
347
- /** The run's link target under a point, if anyfor click-to-follow.
348
- * Not part of the selection seam: core deliberately left hover and
349
- * `cursorAt` out of #291, so following a link stays this package's. */
379
+ /** The run's link target under a **logical** window pointthe one a
380
+ * mouse event carries if any, for click-to-follow. Not part of the
381
+ * selection seam: core deliberately left hover and `cursorAt` out of
382
+ * #291, so following a link stays this package's. */
350
383
  hrefAtPoint(x: number, y: number): string | null {
351
384
  const layout = this._paintLayout();
352
385
  if (!layout) return null;
353
- const lx = x - this.abs.x;
354
- const ly = y - this.abs.y;
386
+ const s = this._scale;
387
+ const lx = x * s - this.abs.x;
388
+ const ly = y * s - this.abs.y;
355
389
  for (const line of layout.lines) {
356
390
  if (ly < line.y || ly >= line.y + line.height) continue;
357
391
  for (const r of line.runs) {
358
- const href = r.span.href;
392
+ const href = r.span?.href;
359
393
  if (href == null) continue;
360
394
  if (lx >= line.x + r.x && lx <= line.x + r.x + r.width) return href;
361
395
  }
@@ -370,12 +404,13 @@ export class RichTextNode extends Node {
370
404
  const layout = this._paintLayout();
371
405
  if (!layout || !canFill(ctx)) return;
372
406
  const { x, y } = this.abs;
407
+ const s = this._scale;
373
408
 
374
409
  ctx.save();
375
410
 
376
411
  // 1. run decorations that sit under everything: the code chip, and the
377
412
  // terminal's cell backgrounds
378
- for (const line of layout.lines) paintRunBackgrounds(ctx, line, x, y);
413
+ for (const line of layout.lines) paintRunBackgrounds(ctx, line, x, y, s);
379
414
 
380
415
  // 2. the band the document selection has claimed of this element's text,
381
416
  // translucent so the ink keeps its contrast on either palette (the
@@ -399,7 +434,7 @@ export class RichTextNode extends Node {
399
434
  layout.draw(ctx, x, y);
400
435
 
401
436
  // 4. rules over the ink: link underlines, strikethrough
402
- for (const line of layout.lines) paintRunRules(ctx, line, x, y);
437
+ for (const line of layout.lines) paintRunRules(ctx, line, x, y, s);
403
438
 
404
439
  ctx.restore();
405
440
  }
@@ -29,15 +29,26 @@ export function canFill(ctx: unknown): ctx is FillContext {
29
29
  return typeof (ctx as Partial<FillContext> | null)?.fillRect === 'function';
30
30
  }
31
31
 
32
- /** One run of one laid-out line, as much of it as decoration reads. */
32
+ /**
33
+ * One run of one laid-out line, as much of it as decoration reads.
34
+ *
35
+ * `span` and `run` are what ntk's layout hands back and what every
36
+ * decoration is read from: the span the run came from, markers and all, and
37
+ * the face it was shaped with. Both are optional because react-x11's Cocoa
38
+ * engine (2.3.x) reports only a run's geometry — `{ x, width, start, end }`
39
+ * — and a paint that assumed the rest threw on the first paragraph on
40
+ * macOS. A run without its span has no decoration to draw; a run without
41
+ * its face takes its vertical extent from the line, which both engines
42
+ * report.
43
+ */
33
44
  export interface LaidRun {
34
45
  x: number;
35
46
  width: number;
36
47
  /** Extent within the laid-out text, in **code units** (ntk's vocabulary). */
37
48
  start: number;
38
49
  end: number;
39
- span: TextRun;
40
- run: {
50
+ span?: TextRun;
51
+ run?: {
41
52
  font: { metrics(size: number): { ascent: number; descent: number } };
42
53
  size: number;
43
54
  direction?: 'ltr' | 'rtl';
@@ -50,6 +61,11 @@ export interface LaidLine {
50
61
  y: number;
51
62
  height: number;
52
63
  baseline: number;
64
+ /** The tallest face on the line, above and below `baseline`. Both engines
65
+ * report them; they stand in for a run's own metrics when the engine did
66
+ * not hand the run's face back. */
67
+ ascent?: number;
68
+ descent?: number;
53
69
  start: number;
54
70
  end: number;
55
71
  runs: LaidRun[];
@@ -65,24 +81,76 @@ export interface CaretSource {
65
81
  };
66
82
  }
67
83
 
84
+ /**
85
+ * The vertical extent of a run's ink, above and below the baseline: the
86
+ * run's own face when the engine handed it back, otherwise the line's.
87
+ */
88
+ function inkExtent(
89
+ r: LaidRun,
90
+ line: LaidLine,
91
+ ): { ascent: number; descent: number } {
92
+ if (r.run) return r.run.font.metrics(r.run.size);
93
+ return {
94
+ ascent: line.ascent ?? line.baseline - line.y,
95
+ descent: line.descent ?? line.y + line.height - line.baseline,
96
+ };
97
+ }
98
+
99
+ let warnedSpanless = false;
100
+
101
+ /**
102
+ * Say so, once, when a laid-out run arrives without the span it came from:
103
+ * every decoration is read off that span, so none will be drawn, and a
104
+ * document that suddenly has no code chips or link underlines should not be
105
+ * a mystery. react-x11's Cocoa engine (2.3.x) is the one that does this.
106
+ *
107
+ * `process` and `console` come off `globalThis` because `src/` compiles
108
+ * with `types: []` — a Node global that wandered in would fail the build
109
+ * rather than become an implicit `@types/node` dependency.
110
+ */
111
+ function warnSpanless(): void {
112
+ if (warnedSpanless) return;
113
+ warnedSpanless = true;
114
+ const g = globalThis as {
115
+ process?: { env?: Record<string, string | undefined> };
116
+ console?: { warn(message: string): void };
117
+ };
118
+ if (g.process?.env?.NODE_ENV === 'production') return;
119
+ g.console?.warn(
120
+ '@react-x11/components: the text engine handed back laid-out runs ' +
121
+ 'without their spans, so run decorations (backgrounds, underlines, ' +
122
+ "strikethrough) and inline link hit-testing are off. react-x11's " +
123
+ "Cocoa text engine (2.3.x) does this; ntk's does not.",
124
+ );
125
+ }
126
+
68
127
  /**
69
128
  * The fills that sit **under** the glyphs: the inline-code chip, a
70
129
  * terminal's cell backgrounds, a highlighted `<span>`.
71
130
  *
72
131
  * `dx`/`dy` translate the line's own coordinates into the target space, so a
73
- * caller that placed this line itself passes where it placed it.
132
+ * caller that placed this line itself passes where it placed it. `scale` is
133
+ * the display scale: the chip's two-pixel inset is a logical length that
134
+ * never passes through a style, and grows with it.
74
135
  */
75
136
  export function paintRunBackgrounds(
76
137
  ctx: FillContext,
77
138
  line: LaidLine,
78
139
  dx: number,
79
140
  dy: number,
141
+ scale = 1,
80
142
  ): void {
143
+ const inset = Math.round(2 * scale);
81
144
  for (const r of line.runs) {
82
- const bg = r.span.bg;
145
+ const span = r.span;
146
+ if (!span) {
147
+ warnSpanless();
148
+ continue;
149
+ }
150
+ const bg = span.bg;
83
151
  if (!bg) continue;
84
152
  ctx.fillStyle = bg;
85
- if (r.span.bgFill === 'line') {
153
+ if (span.bgFill === 'line') {
86
154
  // Both edges are rounded from absolute positions rather than the left
87
155
  // being rounded and the width ceiled, so two adjacent runs agree on the
88
156
  // pixel between them: no seam, and no overlap either.
@@ -96,56 +164,70 @@ export function paintRunBackgrounds(
96
164
  );
97
165
  continue;
98
166
  }
99
- const m = r.run.font.metrics(r.run.size);
167
+ const m = inkExtent(r, line);
100
168
  ctx.fillRect(
101
- Math.round(dx + line.x + r.x - 2),
169
+ Math.round(dx + line.x + r.x - inset),
102
170
  Math.round(dy + line.baseline - m.ascent),
103
- Math.ceil(r.width + 4),
171
+ Math.ceil(r.width + inset * 2),
104
172
  Math.ceil(m.ascent + m.descent),
105
173
  );
106
174
  }
107
175
  }
108
176
 
109
- /** The rules that sit **over** the glyphs: link underlines, strikethrough. */
177
+ /** The rules that sit **over** the glyphs: link underlines, strikethrough.
178
+ * A rule is one logical pixel thick — `scale` device pixels, so a link on a
179
+ * 2x panel is underlined as heavily as on a 1x one, not with a hairline. */
110
180
  export function paintRunRules(
111
181
  ctx: FillContext,
112
182
  line: LaidLine,
113
183
  dx: number,
114
184
  dy: number,
185
+ scale = 1,
115
186
  ): void {
187
+ const t = ruleThickness(scale);
116
188
  for (const r of line.runs) {
117
- const { underline, strike } = r.span;
189
+ const span = r.span;
190
+ if (!span) continue;
191
+ const { underline, strike } = span;
118
192
  if (underline) {
119
193
  ctx.fillStyle = underline;
120
194
  underlineRule(
121
195
  ctx,
122
196
  Math.round(dx + line.x + r.x),
123
- Math.round(dy + line.baseline + 2),
197
+ Math.round(dy + line.baseline + 2 * t),
124
198
  Math.ceil(r.width),
125
- r.span.underlineStyle ?? 'single',
199
+ span.underlineStyle ?? 'single',
200
+ t,
126
201
  );
127
202
  }
128
203
  if (strike) {
129
- const m = r.run.font.metrics(r.run.size);
204
+ const m = inkExtent(r, line);
130
205
  ctx.fillStyle = strike;
131
206
  ctx.fillRect(
132
207
  Math.round(dx + line.x + r.x),
133
208
  Math.round(dy + line.baseline - m.ascent * 0.38),
134
209
  Math.ceil(r.width),
135
- 1,
210
+ t,
136
211
  );
137
212
  }
138
213
  }
139
214
  }
140
215
 
216
+ /** One logical pixel on the device grid, never less than one device pixel. */
217
+ function ruleThickness(scale: number): number {
218
+ return Math.max(1, Math.round(scale));
219
+ }
220
+
141
221
  /**
142
222
  * The rule under a run, in one of SGR 4's five styles.
143
223
  *
144
- * All five are built from 1px rectangles rather than a stroked path: the mock
145
- * backend has no path API, and a hairline stroke on a text baseline is not
146
- * worth an antialiased path even where there is one. The curl is a two-level
147
- * square wave at a text size it reads as a squiggle, which is the entire
148
- * job.
224
+ * All five are built from rectangles `t` pixels thick one logical pixel —
225
+ * rather than a stroked path: the mock backend has no path API, and a
226
+ * hairline stroke on a text baseline is not worth an antialiased path even
227
+ * where there is one. The curl is a two-level square wave at a text size
228
+ * it reads as a squiggle, which is the entire job. The dot pitch and the
229
+ * dash length scale with the thickness, so the pattern is the pattern at
230
+ * any display scale.
149
231
  */
150
232
  export function underlineRule(
151
233
  ctx: FillContext,
@@ -153,31 +235,34 @@ export function underlineRule(
153
235
  y: number,
154
236
  width: number,
155
237
  style: NonNullable<TextRun['underlineStyle']>,
238
+ t = 1,
156
239
  ): void {
157
240
  switch (style) {
158
241
  case 'double':
159
- ctx.fillRect(x, y, width, 1);
160
- ctx.fillRect(x, y + 2, width, 1);
242
+ ctx.fillRect(x, y, width, t);
243
+ ctx.fillRect(x, y + 2 * t, width, t);
161
244
  return;
162
245
  case 'dotted':
163
- for (let i = 0; i < width; i += 2) ctx.fillRect(x + i, y, 1, 1);
246
+ for (let i = 0; i < width; i += 2 * t) {
247
+ ctx.fillRect(x + i, y, Math.min(t, width - i), t);
248
+ }
164
249
  return;
165
250
  case 'dashed':
166
- for (let i = 0; i < width; i += 6)
167
- ctx.fillRect(x + i, y, Math.min(3, width - i), 1);
251
+ for (let i = 0; i < width; i += 6 * t)
252
+ ctx.fillRect(x + i, y, Math.min(3 * t, width - i), t);
168
253
  return;
169
254
  case 'curly':
170
- for (let i = 0; i < width; i += 2) {
255
+ for (let i = 0; i < width; i += 2 * t) {
171
256
  ctx.fillRect(
172
257
  x + i,
173
- y + (i % 4 === 0 ? 0 : 1),
174
- Math.min(2, width - i),
175
- 1,
258
+ y + (i % (4 * t) === 0 ? 0 : t),
259
+ Math.min(2 * t, width - i),
260
+ t,
176
261
  );
177
262
  }
178
263
  return;
179
264
  default:
180
- ctx.fillRect(x, y, width, 1);
265
+ ctx.fillRect(x, y, width, t);
181
266
  return;
182
267
  }
183
268
  }