@react-x11/components 0.3.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 (314) hide show
  1. package/README.md +131 -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 +11 -1
  80. package/dist/index.d.ts.map +1 -1
  81. package/dist/index.js +26 -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/qml/containers.d.ts +3 -0
  140. package/dist/qml/containers.d.ts.map +1 -0
  141. package/dist/qml/containers.js +331 -0
  142. package/dist/qml/containers.js.map +1 -0
  143. package/dist/qml/controls.d.ts +12 -0
  144. package/dist/qml/controls.d.ts.map +1 -0
  145. package/dist/qml/controls.js +21 -0
  146. package/dist/qml/controls.js.map +1 -0
  147. package/dist/qml/globals.d.ts +6 -0
  148. package/dist/qml/globals.d.ts.map +1 -0
  149. package/dist/qml/globals.js +25 -0
  150. package/dist/qml/globals.js.map +1 -0
  151. package/dist/qml/index.d.ts +15 -0
  152. package/dist/qml/index.d.ts.map +1 -0
  153. package/dist/qml/index.js +25 -0
  154. package/dist/qml/index.js.map +1 -0
  155. package/dist/qml/interaction.d.ts +12 -0
  156. package/dist/qml/interaction.d.ts.map +1 -0
  157. package/dist/qml/interaction.js +246 -0
  158. package/dist/qml/interaction.js.map +1 -0
  159. package/dist/qml/ir.d.ts +106 -0
  160. package/dist/qml/ir.d.ts.map +1 -0
  161. package/dist/qml/ir.js +7 -0
  162. package/dist/qml/ir.js.map +1 -0
  163. package/dist/qml/layouts.d.ts +3 -0
  164. package/dist/qml/layouts.d.ts.map +1 -0
  165. package/dist/qml/layouts.js +122 -0
  166. package/dist/qml/layouts.js.map +1 -0
  167. package/dist/qml/models.d.ts +14 -0
  168. package/dist/qml/models.d.ts.map +1 -0
  169. package/dist/qml/models.js +80 -0
  170. package/dist/qml/models.js.map +1 -0
  171. package/dist/qml/objects.d.ts +228 -0
  172. package/dist/qml/objects.d.ts.map +1 -0
  173. package/dist/qml/objects.js +1103 -0
  174. package/dist/qml/objects.js.map +1 -0
  175. package/dist/qml/parse.d.ts +10 -0
  176. package/dist/qml/parse.d.ts.map +1 -0
  177. package/dist/qml/parse.js +607 -0
  178. package/dist/qml/parse.js.map +1 -0
  179. package/dist/qml/qtquick.d.ts +7 -0
  180. package/dist/qml/qtquick.d.ts.map +1 -0
  181. package/dist/qml/qtquick.js +347 -0
  182. package/dist/qml/qtquick.js.map +1 -0
  183. package/dist/qml/react.d.ts +83 -0
  184. package/dist/qml/react.d.ts.map +1 -0
  185. package/dist/qml/react.js +323 -0
  186. package/dist/qml/react.js.map +1 -0
  187. package/dist/qml/resolver.d.ts +38 -0
  188. package/dist/qml/resolver.d.ts.map +1 -0
  189. package/dist/qml/resolver.js +64 -0
  190. package/dist/qml/resolver.js.map +1 -0
  191. package/dist/qml/slots.d.ts +65 -0
  192. package/dist/qml/slots.d.ts.map +1 -0
  193. package/dist/qml/slots.js +230 -0
  194. package/dist/qml/slots.js.map +1 -0
  195. package/dist/qml/states.d.ts +5 -0
  196. package/dist/qml/states.d.ts.map +1 -0
  197. package/dist/qml/states.js +276 -0
  198. package/dist/qml/states.js.map +1 -0
  199. package/dist/qml/view-utils.d.ts +42 -0
  200. package/dist/qml/view-utils.d.ts.map +1 -0
  201. package/dist/qml/view-utils.js +121 -0
  202. package/dist/qml/view-utils.js.map +1 -0
  203. package/dist/reorder/hx.d.ts +18 -0
  204. package/dist/reorder/hx.d.ts.map +1 -0
  205. package/dist/reorder/hx.js +10 -0
  206. package/dist/reorder/hx.js.map +1 -0
  207. package/dist/reorder/index.d.ts +314 -0
  208. package/dist/reorder/index.d.ts.map +1 -0
  209. package/dist/reorder/index.js +1539 -0
  210. package/dist/reorder/index.js.map +1 -0
  211. package/dist/reorder/model.d.ts +107 -0
  212. package/dist/reorder/model.d.ts.map +1 -0
  213. package/dist/reorder/model.js +162 -0
  214. package/dist/reorder/model.js.map +1 -0
  215. package/dist/richtext/node.d.ts +15 -5
  216. package/dist/richtext/node.d.ts.map +1 -1
  217. package/dist/richtext/node.js +40 -11
  218. package/dist/richtext/node.js.map +1 -1
  219. package/dist/richtext/runs.d.ts +35 -13
  220. package/dist/richtext/runs.d.ts.map +1 -1
  221. package/dist/richtext/runs.js +83 -27
  222. package/dist/richtext/runs.js.map +1 -1
  223. package/dist/tabs/hx.d.ts +18 -0
  224. package/dist/tabs/hx.d.ts.map +1 -0
  225. package/dist/tabs/hx.js +10 -0
  226. package/dist/tabs/hx.js.map +1 -0
  227. package/dist/tabs/index.d.ts +160 -0
  228. package/dist/tabs/index.d.ts.map +1 -0
  229. package/dist/tabs/index.js +1265 -0
  230. package/dist/tabs/index.js.map +1 -0
  231. package/dist/terminal/vt/fonts.d.ts +45 -15
  232. package/dist/terminal/vt/fonts.d.ts.map +1 -1
  233. package/dist/terminal/vt/fonts.js +89 -19
  234. package/dist/terminal/vt/fonts.js.map +1 -1
  235. package/dist/terminal/vt/index.d.ts +1 -1
  236. package/dist/terminal/vt/index.d.ts.map +1 -1
  237. package/dist/terminal/vt/index.js +3 -3
  238. package/dist/terminal/vt/index.js.map +1 -1
  239. package/dist/terminal/vt/node.d.ts +42 -0
  240. package/dist/terminal/vt/node.d.ts.map +1 -1
  241. package/dist/terminal/vt/node.js +95 -11
  242. package/dist/terminal/vt/node.js.map +1 -1
  243. package/dist/terminal/vt/pty.d.ts +16 -0
  244. package/dist/terminal/vt/pty.d.ts.map +1 -1
  245. package/dist/terminal/vt/pty.js +186 -0
  246. package/dist/terminal/vt/pty.js.map +1 -1
  247. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  248. package/dist/terminal/vt/renderer.js +13 -3
  249. package/dist/terminal/vt/renderer.js.map +1 -1
  250. package/package.json +23 -3
  251. package/src/charts/components.ts +14 -7
  252. package/src/charts/node.ts +81 -26
  253. package/src/charts/render.ts +16 -6
  254. package/src/code-editor/node.ts +147 -53
  255. package/src/flow/draw.ts +104 -35
  256. package/src/flow/index.ts +33 -13
  257. package/src/flow/model.ts +4 -2
  258. package/src/flow/node.ts +158 -59
  259. package/src/flow/types.ts +46 -21
  260. package/src/formula/index.ts +4 -2
  261. package/src/formula/node.ts +24 -5
  262. package/src/html/controls.ts +4 -1
  263. package/src/html/css/cascade.ts +18 -7
  264. package/src/html/css/parse.ts +46 -21
  265. package/src/html/css/style.ts +6 -1
  266. package/src/html/css/values.ts +25 -9
  267. package/src/html/index.ts +1 -0
  268. package/src/html/layout/boxes.ts +19 -9
  269. package/src/html/layout/inline.ts +23 -1
  270. package/src/html/node.ts +110 -17
  271. package/src/html/paint.ts +17 -2
  272. package/src/index.ts +142 -0
  273. package/src/maps/clip.ts +184 -0
  274. package/src/maps/gzip.ts +66 -0
  275. package/src/maps/index.ts +316 -0
  276. package/src/maps/labels.ts +442 -0
  277. package/src/maps/mvt.ts +857 -0
  278. package/src/maps/node.ts +1819 -0
  279. package/src/maps/overlay.ts +639 -0
  280. package/src/maps/paint.ts +978 -0
  281. package/src/maps/proj.ts +641 -0
  282. package/src/maps/sources.ts +425 -0
  283. package/src/maps/style.ts +309 -0
  284. package/src/maps/styles.ts +1040 -0
  285. package/src/maps/tiles.ts +676 -0
  286. package/src/maps/types.ts +260 -0
  287. package/src/qml/containers.tsx +380 -0
  288. package/src/qml/controls.tsx +47 -0
  289. package/src/qml/globals.ts +33 -0
  290. package/src/qml/index.ts +67 -0
  291. package/src/qml/interaction.tsx +317 -0
  292. package/src/qml/ir.ts +80 -0
  293. package/src/qml/layouts.tsx +141 -0
  294. package/src/qml/models.ts +101 -0
  295. package/src/qml/objects.ts +1431 -0
  296. package/src/qml/parse.ts +624 -0
  297. package/src/qml/qtquick.tsx +413 -0
  298. package/src/qml/react.tsx +454 -0
  299. package/src/qml/resolver.ts +99 -0
  300. package/src/qml/slots.ts +249 -0
  301. package/src/qml/states.ts +318 -0
  302. package/src/qml/view-utils.ts +140 -0
  303. package/src/reorder/hx.ts +31 -0
  304. package/src/reorder/index.ts +2223 -0
  305. package/src/reorder/model.ts +232 -0
  306. package/src/richtext/node.ts +49 -14
  307. package/src/richtext/runs.ts +115 -30
  308. package/src/tabs/hx.ts +31 -0
  309. package/src/tabs/index.ts +1686 -0
  310. package/src/terminal/vt/fonts.ts +128 -36
  311. package/src/terminal/vt/index.ts +4 -2
  312. package/src/terminal/vt/node.ts +105 -12
  313. package/src/terminal/vt/pty.ts +243 -0
  314. package/src/terminal/vt/renderer.ts +13 -3
package/src/flow/node.ts CHANGED
@@ -38,7 +38,7 @@ import {
38
38
  XK_UP,
39
39
  } from 'react-x11/keysyms';
40
40
 
41
- import { createPainter, measureText } from './draw.js';
41
+ import { createPainter, measureText, toDevice } from './draw.js';
42
42
  import type { CachedText, FontsLike, PainterOptions } from './draw.js';
43
43
  import {
44
44
  boundsOf,
@@ -608,6 +608,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
608
608
  this._themeString('fontFamily') ??
609
609
  'sans-serif',
610
610
  color: this._palette().text,
611
+ scale: this._scale,
611
612
  cache: this._textCache,
612
613
  };
613
614
  }
@@ -891,17 +892,71 @@ export class FlowGraphNode extends Node implements FlowInstance {
891
892
  return this._prop<Viewport>('viewport') ?? this._vp;
892
893
  }
893
894
 
895
+ /**
896
+ * Device pixels per logical pixel — the display scale this pane's window
897
+ * resolved to (react-x11's docs/scale.md): `1` on an ordinary display,
898
+ * `2` on a retina panel, fractional on a desktop configured to 1.5.
899
+ *
900
+ * The pane thinks in logical pixels throughout. They are the unit its API
901
+ * speaks (a viewport, a node's size, a `NodeBodyRect`), the unit a
902
+ * synthetic event's `x`/`y` arrive in, and the unit the sibling boxes
903
+ * `<Flow>` mounts bodies in are laid out in. What core hands this element
904
+ * is device pixels — `abs`, `contentBox()`, `this.style`, the paint
905
+ * context, `paintDamage()`, a rect given to `invalidate` or
906
+ * `scrollContents`, an a11y scene rect — so each of those crossings
907
+ * converts, once, in the helpers below, and nothing in between knows the
908
+ * factor. At 1x every conversion is the identity, which is how a pane
909
+ * that compared `ev.x` with `contentBox()` passed every test and then
910
+ * hovered at half the distance, panned at half speed and framed the graph
911
+ * at half size the day a native backend reported a retina panel.
912
+ */
913
+ private get _scale(): number {
914
+ return this.scale > 0 ? this.scale : 1;
915
+ }
916
+
917
+ /** A logical rect on the device grid, grown outward to whole pixels —
918
+ * what a damage claim has to cover for an antialiased edge to repaint. */
919
+ private _device(rect: FlowRect): FlowRect {
920
+ const s = this._scale;
921
+ const x = Math.floor(toDevice(rect.x, s));
922
+ const y = Math.floor(toDevice(rect.y, s));
923
+ return {
924
+ x,
925
+ y,
926
+ width: Math.ceil(toDevice(rect.x + rect.width, s)) - x,
927
+ height: Math.ceil(toDevice(rect.y + rect.height, s)) - y,
928
+ };
929
+ }
930
+
931
+ /** One of core's device rects in the pane's logical units. */
932
+ private _logical(rect: FlowRect): FlowRect {
933
+ const s = this._scale;
934
+ return {
935
+ x: rect.x / s,
936
+ y: rect.y / s,
937
+ width: rect.width / s,
938
+ height: rect.height / s,
939
+ };
940
+ }
941
+
942
+ /** Claim a logical rect as damage. Every partial repaint the pane asks
943
+ * for goes through here; the full ones pass `abs`, which is device
944
+ * already. */
945
+ private _claim(rect: FlowRect, reason: string): void {
946
+ this.invalidate(false, this._device(rect), reason);
947
+ }
948
+
894
949
  /**
895
950
  * The pane proper: `abs` inset by border and padding, off core's own
896
951
  * resolved layout (react-x11#254) — so a `<Flow style={{ borderWidth: 1,
897
952
  * padding: 8 }}>` keeps its border, and its padding means what it does on
898
- * a `<box>`.
953
+ * a `<box>`. In logical pixels, like everything the pane computes.
899
954
  */
900
955
  private _pane(): FlowRect {
901
- return this.contentBox();
956
+ return this._logical(this.contentBox());
902
957
  }
903
958
 
904
- /** Graph point to window pixels. */
959
+ /** Graph point to logical window pixels. */
905
960
  private _toScreen(p: XYPosition): XYPosition {
906
961
  const v = this._viewport();
907
962
  const pane = this._pane();
@@ -911,7 +966,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
911
966
  };
912
967
  }
913
968
 
914
- /** Window pixels to graph point. */
969
+ /** Logical window pixels to graph point. */
915
970
  private _toGraph(x: number, y: number): XYPosition {
916
971
  const v = this._viewport();
917
972
  const pane = this._pane();
@@ -978,7 +1033,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
978
1033
  }
979
1034
 
980
1035
  /**
981
- * A node's box on screen, **on whole pixels**.
1036
+ * A node's box on screen, **on whole device pixels**.
982
1037
  *
983
1038
  * The rounding is not cosmetic. ntk draws a rounded box as cached corner
984
1039
  * glyphs plus `FillRectangles` when its geometry is integral, and
@@ -987,22 +1042,26 @@ export class FlowGraphNode extends Node implements FlowInstance {
987
1042
  * six hundred mask uploads a frame and about four megabytes on the wire;
988
1043
  * `react-x11/debug`'s trace names it as `fell back … fractional`.
989
1044
  *
990
- * Hit testing reads the same rect, so what is drawn and what is clicked
991
- * still agree to the pixel.
1045
+ * The grid is the panel's, not the logical one: at 2x a logical half is
1046
+ * a whole pixel, and at 1.5x a logical integer is not always one. Hit
1047
+ * testing reads the same rect, so what is drawn and what is clicked still
1048
+ * agree to the pixel.
992
1049
  */
993
1050
  private _screenRect(entry: NodeEntry): FlowRect {
994
1051
  const v = this._viewport();
995
1052
  const rect = this.rectOf(entry);
996
1053
  const p = this._toScreen(rect);
997
- const x = Math.round(p.x);
998
- const y = Math.round(p.y);
1054
+ const s = this._scale;
1055
+ const grid = (value: number): number => Math.round(value * s) / s;
1056
+ const x = grid(p.x);
1057
+ const y = grid(p.y);
999
1058
  return {
1000
1059
  x,
1001
1060
  y,
1002
1061
  // rounded as edges rather than as a size, so two nodes that share a
1003
1062
  // column still share it after rounding
1004
- width: Math.round(p.x + rect.width * v.zoom) - x,
1005
- height: Math.round(p.y + rect.height * v.zoom) - y,
1063
+ width: grid(p.x + rect.width * v.zoom) - x,
1064
+ height: grid(p.y + rect.height * v.zoom) - y,
1006
1065
  };
1007
1066
  }
1008
1067
 
@@ -1051,8 +1110,8 @@ export class FlowGraphNode extends Node implements FlowInstance {
1051
1110
  * panels repaint in place.
1052
1111
  *
1053
1112
  * Still a full repaint when:
1054
- * - the zoom moved (scaling is not a blit) or the shift is fractional
1055
- * every real pan gesture is whole pixels;
1113
+ * - the zoom moved (scaling is not a blit) or the shift is fractional on
1114
+ * the device grid — every real pan gesture is whole device pixels;
1056
1115
  * - mounted node bodies exist: they are core-owned siblings whose
1057
1116
  * gesture-time commits claim *inside* the rect every step, which
1058
1117
  * declines the blit anyway — bailing early skips the churn;
@@ -1062,10 +1121,16 @@ export class FlowGraphNode extends Node implements FlowInstance {
1062
1121
  private _blitPan(previous: Viewport, next: Viewport): boolean {
1063
1122
  if (next.zoom !== previous.zoom) return false;
1064
1123
  if (this._bodiesKey !== '') return false;
1065
- const dx = Math.round(next.x - previous.x);
1066
- const dy = Math.round(next.y - previous.y);
1124
+ // Device pixels: the blit copies the backing store, and its grid is the
1125
+ // panel's. A pointer step lands on it whatever the scale — it came off
1126
+ // the wire as whole device pixels — so every real pan gesture blits.
1127
+ const s = this._scale;
1128
+ const shiftX = toDevice(next.x - previous.x, s);
1129
+ const shiftY = toDevice(next.y - previous.y, s);
1130
+ const dx = Math.round(shiftX);
1131
+ const dy = Math.round(shiftY);
1067
1132
  if (dx === 0 && dy === 0) return true; // sub-pixel: nothing to show yet
1068
- if (next.x - previous.x !== dx || next.y - previous.y !== dy) return false;
1133
+ if (shiftX !== dx || shiftY !== dy) return false;
1069
1134
  const pane = this._pane();
1070
1135
 
1071
1136
  // The horizontal bands the furniture lives in. Full-width, because the
@@ -1100,32 +1165,39 @@ export class FlowGraphNode extends Node implements FlowInstance {
1100
1165
  this._controlsOptions()?.position ?? 'bottom-left',
1101
1166
  );
1102
1167
  }
1168
+ // From here on, device pixels: `scrollContents` and the claims speak
1169
+ // core's units. The bands round *up* and the blit is what is left, so
1170
+ // the three sit edge to edge on whole pixels — a claim overlapping the
1171
+ // blit rect by a pixel is a foreign claim, and declines the blit.
1172
+ const box = this.contentBox();
1173
+ const top = Math.ceil(toDevice(topBand, s));
1174
+ const bottom = Math.ceil(toDevice(bottomBand, s));
1103
1175
  const blit: FlowRect = {
1104
- x: pane.x,
1105
- y: pane.y + topBand,
1106
- width: pane.width,
1107
- height: pane.height - topBand - bottomBand,
1176
+ x: box.x,
1177
+ y: box.y + top,
1178
+ width: box.width,
1179
+ height: box.height - top - bottom,
1108
1180
  };
1109
- if (blit.width < 64 || blit.height < 64) return false;
1181
+ if (blit.width < 64 * s || blit.height < 64 * s) return false;
1110
1182
  if (Math.abs(dx) >= blit.width || Math.abs(dy) >= blit.height) {
1111
1183
  return false;
1112
1184
  }
1113
1185
  this.scrollContents(blit, dx, dy);
1114
- if (topBand > 0) {
1186
+ if (top > 0) {
1115
1187
  this.invalidate(
1116
1188
  false,
1117
- { x: pane.x, y: pane.y, width: pane.width, height: topBand },
1189
+ { x: box.x, y: box.y, width: box.width, height: top },
1118
1190
  'scroll',
1119
1191
  );
1120
1192
  }
1121
- if (bottomBand > 0) {
1193
+ if (bottom > 0) {
1122
1194
  this.invalidate(
1123
1195
  false,
1124
1196
  {
1125
- x: pane.x,
1197
+ x: box.x,
1126
1198
  y: blit.y + blit.height,
1127
- width: pane.width,
1128
- height: bottomBand,
1199
+ width: box.width,
1200
+ height: bottom,
1129
1201
  },
1130
1202
  'scroll',
1131
1203
  );
@@ -1881,8 +1953,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
1881
1953
  // The box outline is all that moves this step; a node crossing the
1882
1954
  // boundary changes `selected`, and that repaints through the
1883
1955
  // controlled round trip like any other selection change.
1884
- this.invalidate(
1885
- false,
1956
+ this._claim(
1886
1957
  inflateRect(unionRects(oldBox, selectBoxRect(gesture)), CULL_MARGIN),
1887
1958
  'style-state',
1888
1959
  );
@@ -2093,7 +2164,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
2093
2164
  }
2094
2165
  this._emitNodes(changes);
2095
2166
  if (damage) {
2096
- this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'content');
2167
+ this._claim(inflateRect(damage, CULL_MARGIN), 'content');
2097
2168
  } else {
2098
2169
  this._repaint();
2099
2170
  }
@@ -2152,7 +2223,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
2152
2223
  });
2153
2224
  }
2154
2225
  this._emitNodes(changes);
2155
- this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'content');
2226
+ this._claim(inflateRect(damage, CULL_MARGIN), 'content');
2156
2227
  this._emitBodies();
2157
2228
  }
2158
2229
 
@@ -2213,8 +2284,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
2213
2284
  const target = to ? this._byId.get(to.nodeId) : null;
2214
2285
  if (target) box = unionRects(box, this._screenRect(target));
2215
2286
  gesture.box = box;
2216
- this.invalidate(
2217
- false,
2287
+ this._claim(
2218
2288
  inflateRect(oldBox ? unionRects(oldBox, box) : box, CULL_MARGIN),
2219
2289
  'style-state',
2220
2290
  );
@@ -2346,7 +2416,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
2346
2416
  );
2347
2417
  this._hover = next;
2348
2418
  if (damage) {
2349
- this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'style-state');
2419
+ this._claim(inflateRect(damage, CULL_MARGIN), 'style-state');
2350
2420
  } else {
2351
2421
  this._repaint('style-state');
2352
2422
  }
@@ -2357,7 +2427,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
2357
2427
  const damage = this._hoverDamage(this._hover);
2358
2428
  this._hover = NO_HOVER;
2359
2429
  if (damage) {
2360
- this.invalidate(false, inflateRect(damage, CULL_MARGIN), 'style-state');
2430
+ this._claim(inflateRect(damage, CULL_MARGIN), 'style-state');
2361
2431
  } else {
2362
2432
  this._repaint('style-state');
2363
2433
  }
@@ -2403,7 +2473,8 @@ export class FlowGraphNode extends Node implements FlowInstance {
2403
2473
  const degree = out.length;
2404
2474
  items.push({
2405
2475
  id: entry.node.id,
2406
- rect,
2476
+ // the scene's rects are `abs`'s space — device pixels
2477
+ rect: this._device(rect),
2407
2478
  role: 'listitem',
2408
2479
  name: data?.label ?? entry.node.id,
2409
2480
  description:
@@ -2611,7 +2682,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
2611
2682
  }
2612
2683
  }
2613
2684
  if (damage === 'full') this._repaint('props');
2614
- else if (damage) this.invalidate(false, damage, 'props');
2685
+ else if (damage) this._claim(damage, 'props');
2615
2686
  // Core re-reads the scene for aria-prop commits; a `nodes`/`edges`
2616
2687
  // change is invisible to it, so the re-read is asked for by name.
2617
2688
  // Free when no assistive technology is listening.
@@ -2631,7 +2702,11 @@ export class FlowGraphNode extends Node implements FlowInstance {
2631
2702
  this._dashPhase += ANIMATION_SPEED;
2632
2703
  // the box the last paint saw animated edges in, not the pane: a
2633
2704
  // marching dash should not cost a full grid repaint per tick
2634
- this.invalidate(false, this._animBox ?? this.abs, 'animation');
2705
+ this.invalidate(
2706
+ false,
2707
+ this._animBox ? this._device(this._animBox) : this.abs,
2708
+ 'animation',
2709
+ );
2635
2710
  }, ANIMATION_MS) ?? null;
2636
2711
  }
2637
2712
 
@@ -2688,7 +2763,13 @@ export class FlowGraphNode extends Node implements FlowInstance {
2688
2763
  this._fit(this._prop<FitViewOptions>('fitViewOptions'));
2689
2764
  }
2690
2765
  }
2691
- this._frameClip = this._fitPending ? this._pane() : clip;
2766
+ // `paintDamage()` speaks device pixels, like `abs`; the culling below
2767
+ // speaks logical ones, like everything the pane draws.
2768
+ this._frameClip = this._fitPending
2769
+ ? this._pane()
2770
+ : clip
2771
+ ? this._logical(clip)
2772
+ : null;
2692
2773
 
2693
2774
  const palette = this._palette();
2694
2775
  const { x, y, width, height } = this._pane();
@@ -2698,11 +2779,14 @@ export class FlowGraphNode extends Node implements FlowInstance {
2698
2779
  // clip is the content box rather than `abs` because the border has been
2699
2780
  // stroked already, inside the box, and drawing over it would leave a
2700
2781
  // `borderWidth: 1` looking like half of one.
2701
- const radius = Math.max(
2702
- 0,
2703
- ((this.style.borderRadius as number | undefined) ?? 0) -
2704
- ((this.style.borderWidth as number | undefined) ?? 0),
2705
- );
2782
+ // `this.style` arrives in device pixels (react-x11's docs/scale.md);
2783
+ // the painter takes logical ones.
2784
+ const radius =
2785
+ Math.max(
2786
+ 0,
2787
+ ((this.style.borderRadius as number | undefined) ?? 0) -
2788
+ ((this.style.borderWidth as number | undefined) ?? 0),
2789
+ ) / this._scale;
2706
2790
  // The rounded clip only when this pass can actually reach a corner: a
2707
2791
  // non-rectangular clip forfeits ntk's rounded-box fast path for every
2708
2792
  // fill under it, which multiplies a rounded *pane* into a per-card
@@ -2793,8 +2877,8 @@ export class FlowGraphNode extends Node implements FlowInstance {
2793
2877
  if (width <= 1 || height <= 1) continue;
2794
2878
  bodies.push({
2795
2879
  id: entry.node.id,
2796
- // pane-relative, because that is what an absolutely positioned box
2797
- // beside the pane is laid out against
2880
+ // pane-relative and logical, because that is what an absolutely
2881
+ // positioned box beside the pane is laid out against, and in
2798
2882
  x: Math.round(rect.x + inset - pane.x),
2799
2883
  y: Math.round(rect.y + header - pane.y),
2800
2884
  width: Math.round(width),
@@ -2803,8 +2887,14 @@ export class FlowGraphNode extends Node implements FlowInstance {
2803
2887
  selected: entry.node.selected ?? false,
2804
2888
  });
2805
2889
  }
2890
+ // The zoom is in the key as well as the rect it produced: it is the
2891
+ // body's subtree scale now, and two zooms a rounding apart can leave a
2892
+ // box on the same integers while the text inside it should have moved.
2806
2893
  const key = bodies
2807
- .map((b) => `${b.id}:${b.x},${b.y},${b.width},${b.height},${b.selected}`)
2894
+ .map(
2895
+ (b) =>
2896
+ `${b.id}:${b.x},${b.y},${b.width},${b.height},${b.zoom},${b.selected}`,
2897
+ )
2808
2898
  .join('|');
2809
2899
  if (key === this._bodiesKey) return;
2810
2900
  this._bodiesKey = key;
@@ -2916,8 +3006,13 @@ export class FlowGraphNode extends Node implements FlowInstance {
2916
3006
  color: string,
2917
3007
  region: FlowRect,
2918
3008
  ): boolean {
2919
- const tileSize = Math.round(step);
2920
- if (Math.abs(step - tileSize) > 0.01 || tileSize < 2) return false;
3009
+ // The tile is a pixmap on the panel's grid, so the pitch has to be a
3010
+ // whole number of *device* pixels, and the phase is where the viewport
3011
+ // origin lands on that grid.
3012
+ const s = this._scale;
3013
+ const pitch = toDevice(step, s);
3014
+ const tileSize = Math.round(pitch);
3015
+ if (Math.abs(pitch - tileSize) > 0.01 || tileSize < 2) return false;
2921
3016
  const raw = painter.raw as {
2922
3017
  createPattern?: (source: unknown, repetition: string) => PatternLike;
2923
3018
  fillStyle?: unknown;
@@ -2928,9 +3023,11 @@ export class FlowGraphNode extends Node implements FlowInstance {
2928
3023
  const v = this._viewport();
2929
3024
  const pane = this._pane();
2930
3025
  const mod = (a: number, m: number): number => ((a % m) + m) % m;
2931
- const px = Math.round(mod(pane.x + v.x, tileSize)) % tileSize;
2932
- const py = Math.round(mod(pane.y + v.y, tileSize)) % tileSize;
2933
- const key = `${options.variant}|${Math.round(options.size * v.zoom * 4)}|${color}|${px},${py}`;
3026
+ const px = Math.round(mod(toDevice(pane.x + v.x, s), tileSize)) % tileSize;
3027
+ const py = Math.round(mod(toDevice(pane.y + v.y, s), tileSize)) % tileSize;
3028
+ // device pixels per graph unit — what a mark's size is drawn in
3029
+ const unit = v.zoom * s;
3030
+ const key = `${options.variant}|${Math.round(options.size * unit * 4)}|${color}|${px},${py}`;
2934
3031
 
2935
3032
  let tile = this._gridTile;
2936
3033
  if (!tile || tile.size !== tileSize) {
@@ -2947,10 +3044,11 @@ export class FlowGraphNode extends Node implements FlowInstance {
2947
3044
  }
2948
3045
  if (tile.key !== key) {
2949
3046
  tile.key = key;
2950
- this._renderGridTile(tile, options, v.zoom, color, px, py);
3047
+ this._renderGridTile(tile, options, unit, color, px, py);
2951
3048
  }
2952
3049
  raw.fillStyle = tile.pattern;
2953
- raw.fillRect?.(region.x, region.y, region.width, region.height);
3050
+ const fill = this._device(region);
3051
+ raw.fillRect?.(fill.x, fill.y, fill.width, fill.height);
2954
3052
  return true;
2955
3053
  }
2956
3054
 
@@ -2969,11 +3067,12 @@ export class FlowGraphNode extends Node implements FlowInstance {
2969
3067
  }
2970
3068
 
2971
3069
  /** Draw one grid cell with the mark at the phase point, wrapped — a mark
2972
- * near an edge is drawn again a tile over, so the seam never cuts it. */
3070
+ * near an edge is drawn again a tile over, so the seam never cuts it.
3071
+ * Device pixels throughout: `unit` is how many of them a graph unit is. */
2973
3072
  private _renderGridTile(
2974
3073
  tile: { size: number; surface: SurfaceLike },
2975
3074
  options: { variant: BackgroundVariant; size: number },
2976
- zoom: number,
3075
+ unit: number,
2977
3076
  color: string,
2978
3077
  px: number,
2979
3078
  py: number,
@@ -2990,11 +3089,11 @@ export class FlowGraphNode extends Node implements FlowInstance {
2990
3089
  if (j === 0) ctx.fillRect(cx - 0.5, 0, 1, t);
2991
3090
  if (i === 0) ctx.fillRect(0, cy - 0.5, t, 1);
2992
3091
  } else if (options.variant === 'cross') {
2993
- const arm = Math.max(2, options.size * 3 * zoom);
3092
+ const arm = Math.max(2, options.size * 3 * unit);
2994
3093
  ctx.fillRect(cx - arm, cy - 0.5, arm * 2, 1);
2995
3094
  ctx.fillRect(cx - 0.5, cy - arm, 1, arm * 2);
2996
3095
  } else {
2997
- const dot = Math.max(1, Math.round(options.size * 2 * zoom));
3096
+ const dot = Math.max(1, Math.round(options.size * 2 * unit));
2998
3097
  ctx.fillRect(cx - dot / 2, cy - dot / 2, dot, dot);
2999
3098
  }
3000
3099
  }
package/src/flow/types.ts CHANGED
@@ -30,7 +30,9 @@ export interface XYPosition {
30
30
  y: number;
31
31
  }
32
32
 
33
- /** A box in graph space unless the field it is on says screen pixels. */
33
+ /** A box in graph space unless the field it is on says window pixels
34
+ * which are *logical* pixels, the unit every style length is in, whatever
35
+ * the display's scale (see {@link FlowPainter}). */
34
36
  export interface FlowRect {
35
37
  x: number;
36
38
  y: number;
@@ -40,8 +42,10 @@ export interface FlowRect {
40
42
 
41
43
  /**
42
44
  * The pane's transform: graph point `p` is drawn at `p * zoom + (x, y)`,
43
- * relative to the pane's top-left corner. Translate-then-scale, the same
44
- * order react-flow uses, so a viewport copied from one lands in the other.
45
+ * relative to the pane's top-left corner, in logical pixels. Translate-
46
+ * then-scale, the same order react-flow uses, so a viewport copied from one
47
+ * lands in the other — and one graph unit at zoom 1 is one logical pixel,
48
+ * so a node sized `{ width: 168 }` is as wide as a `<box>` styled the same.
45
49
  */
46
50
  export interface Viewport {
47
51
  x: number;
@@ -276,7 +280,7 @@ export interface TextOptions {
276
280
  /** Where `y` is measured: the top of the line, or its middle. Default
277
281
  * `'top'`. */
278
282
  baseline?: 'top' | 'middle';
279
- /** Ellipsize past this many pixels. */
283
+ /** Ellipsize past this many (logical) pixels. */
280
284
  maxWidth?: number;
281
285
  }
282
286
 
@@ -292,9 +296,12 @@ export interface ShapeOptions extends StrokeOptions {
292
296
  }
293
297
 
294
298
  /**
295
- * What a custom node type draws through. Everything is in **screen pixels**,
296
- * already scaled by the zoom — a node type multiplies its own sizes by
297
- * `zoom` and otherwise ignores the viewport.
299
+ * What a custom node type draws through. Everything is in **logical window
300
+ * pixels**, already scaled by the zoom — the unit a style length is in, so
301
+ * a `13 * zoom` label is the size a `fontSize: 13` label is at zoom 1. A
302
+ * node type multiplies its own sizes by `zoom` and otherwise ignores the
303
+ * viewport, and never thinks about the display: on a 2x panel the painter
304
+ * shapes text and lands geometry on the device grid itself.
298
305
  *
299
306
  * It is a facade rather than ntk's context because the mock backend used by
300
307
  * headless tests has no path API: a node type written against this one is
@@ -303,8 +310,12 @@ export interface ShapeOptions extends StrokeOptions {
303
310
  */
304
311
  export interface FlowPainter {
305
312
  /** ntk's 2d context, for anything the facade does not offer. Null on a
306
- * backend that cannot draw paths. */
313
+ * backend that cannot draw paths. It draws in **device** pixels — the
314
+ * one place in this API that does — so multiply by {@link scale} there. */
307
315
  readonly raw: unknown;
316
+ /** Device pixels per logical pixel: `1` on an ordinary display, `2` on a
317
+ * retina panel. Applied by every method here; only `raw` needs it. */
318
+ readonly scale: number;
308
319
  save(): void;
309
320
  restore(): void;
310
321
  clipRect(x: number, y: number, w: number, h: number, radius?: number): void;
@@ -346,27 +357,37 @@ export interface FlowPainter {
346
357
  /** What a node type is handed to draw one node. */
347
358
  export interface NodePaintContext<Data = FlowNodeData> {
348
359
  node: FlowNode<Data>;
349
- /** The node's box, in screen pixels. */
360
+ /** The node's box, in logical window pixels. */
350
361
  rect: FlowRect;
351
362
  zoom: number;
352
363
  selected: boolean;
353
364
  hovered: boolean;
354
365
  palette: FlowPalette;
355
366
  painter: FlowPainter;
356
- /** The node's handles, resolved and in screen pixels. Drawn by the pane
357
- * after `paint` returns, so a type that wants them somewhere else moves
358
- * them with `handles`, not by drawing its own. */
367
+ /** The node's handles, resolved and in logical window pixels. Drawn by
368
+ * the pane after `paint` returns, so a type that wants them somewhere
369
+ * else moves them with `handles`, not by drawing its own. */
359
370
  handles: readonly HandleAnchor[];
360
371
  }
361
372
 
362
- /** What {@link FlowNodeType.render} is handed. */
373
+ /**
374
+ * What {@link FlowNodeType.render} is handed.
375
+ *
376
+ * Unlike {@link NodePaintContext}, everything here is in **graph units** —
377
+ * the unit the node's own `position` and `width` are in, unscaled by the
378
+ * viewport. The body is mounted in a subtree the zoom scales, so a
379
+ * `fontSize: 11` is 11 graph units at every zoom and the body never
380
+ * multiplies by anything. `zoom` is there for a body that wants to show
381
+ * less of itself when it is small, not for sizing.
382
+ */
363
383
  export interface NodeRenderContext<Data = FlowNodeData> {
364
384
  node: FlowNode<Data>;
365
385
  selected: boolean;
366
386
  zoom: number;
367
- /** The box the returned tree is mounted in and laid out to, in pixels
368
- * relative to the pane's top-left corner. Its size is the useful half;
369
- * the position is there for a body that wants to know where it is. */
387
+ /** The box the returned tree is mounted in and laid out to, in graph
388
+ * units relative to the pane's top-left corner. Its size is the useful
389
+ * half; the position is there for a body that wants to know where it
390
+ * is. */
370
391
  rect: FlowRect;
371
392
  }
372
393
 
@@ -411,11 +432,12 @@ export interface FlowNodeType<Data = FlowNodeData> {
411
432
  * - **The node re-renders as the viewport moves.** This is the cost the
412
433
  * drawn path exists to avoid, so it is paid only by the nodes that ask
413
434
  * for it, and only while they are on screen.
414
- * - **It does not scale with the zoom.** The *box* does there is no
415
- * transform in this renderer, which is the whole reason `paint` is the
416
- * default so content is laid out to the zoomed box at its natural
417
- * size, and clipped. Below `zoom` 0.6 it is not mounted at all and the
418
- * node is drawn instead.
435
+ * - **It zooms with the pane.** The subtree is mounted under a `scale`
436
+ * box (react-x11 2.6), so it is written in graph units plain style
437
+ * lengths, no `zoom` in them and comes out at the size the card is
438
+ * drawn at, text shaped at that size rather than stretched. Below
439
+ * `zoom` 0.6 it is not mounted at all: too small to read, and a
440
+ * mounted subtree per card is what an overview cannot afford.
419
441
  *
420
442
  * `headerHeight` is what leaves the node draggable: the body starts below
421
443
  * it, so there is always somewhere to grab that is not a text field.
@@ -482,6 +504,9 @@ export interface FitViewOptions {
482
504
  */
483
505
  export interface NodeBodyRect extends FlowRect {
484
506
  id: string;
507
+ /** The viewport zoom the rect was measured at — the scale `<Flow>` gives
508
+ * the box it mounts, so that the body inside it is laid out in graph
509
+ * units. */
485
510
  zoom: number;
486
511
  selected: boolean;
487
512
  }
@@ -88,8 +88,10 @@ export interface FormulaProps {
88
88
  * ends: from then on a parse error renders KaTeX's error text.
89
89
  */
90
90
  partial?: boolean;
91
- /** Pixels per em. Default: theme `fontSize` × 1.21, which is the ratio
92
- * katex.css uses so mathematics holds its own beside body text. */
91
+ /** Logical pixels per em the unit a `fontSize` is written in; on a
92
+ * retina panel the element shapes at `size` × the display scale, like
93
+ * any style length. Default: theme `fontSize` × 1.21, which is the
94
+ * ratio katex.css uses so mathematics holds its own beside body text. */
93
95
  size?: number;
94
96
  /** Ink color. Default: theme `text`. */
95
97
  color?: string;
@@ -39,7 +39,8 @@ export interface FormulaElementProps {
39
39
  /** KaTeX's virtual DOM for the expression. Stable identity per source —
40
40
  * the layout cache keys off it. */
41
41
  tree: KatexNode;
42
- /** Pixels per em at the formula's base size. */
42
+ /** Logical pixels per em at the formula's base size — the unit a
43
+ * `fontSize` is written in. The node shapes at `size × scale`. */
43
44
  size: number;
44
45
  /** Ink color for everything the expression does not color itself. */
45
46
  color: string;
@@ -133,7 +134,7 @@ function registerKatexFonts(
133
134
 
134
135
  export class FormulaNode extends Node {
135
136
  private _layout: FormulaLayout | null = null;
136
- private _layoutOf: { tree: KatexNode; size: number; color: string } | null =
137
+ private _layoutOf: { tree: KatexNode; em: number; color: string } | null =
137
138
  null;
138
139
  private _widths = new Map<string, number>();
139
140
  private _minis = new Map<string, MiniLayout | null>();
@@ -179,26 +180,44 @@ export class FormulaNode extends Node {
179
180
  };
180
181
  }
181
182
 
183
+ // --- units ---------------------------------------------------------------
184
+ //
185
+ // The layout, `abs`, the paint and the four text accessors are device
186
+ // pixels (react-x11's docs/scale.md). `size` is pixels per em the way a
187
+ // `fontSize` is written — logical — and, unlike a style length, nothing
188
+ // in core multiplies it on the way in; `_ensureLayout` does, so the
189
+ // glyphs are shaped at `size * scale` and a 17 on a 2x panel is the size
190
+ // the text beside it is, sharper rather than smaller. Everything below
191
+ // reads that one layout, which is what keeps a caret rect, a selection
192
+ // band and the ink agreeing.
193
+
194
+ /** Device pixels per logical pixel — the display scale this element's
195
+ * window resolved to, constant for the node's life. */
196
+ private get _scale(): number {
197
+ return this.scale > 0 ? this.scale : 1;
198
+ }
199
+
182
200
  private _ensureLayout(): FormulaLayout | null {
183
201
  const { tree, size = 14, color = 'black' } = this._props();
184
202
  if (!tree) return null;
203
+ const em = size * this._scale;
185
204
  const prev = this._layoutOf;
186
205
  if (
187
206
  this._layout &&
188
207
  prev &&
189
208
  prev.tree === tree &&
190
- prev.size === size &&
209
+ prev.em === em &&
191
210
  prev.color === color
192
211
  ) {
193
212
  return this._layout;
194
213
  }
195
214
  const fonts = this._fonts();
196
215
  this._layout = layoutFormula(tree, {
197
- em: size,
216
+ em,
198
217
  color,
199
218
  shaper: fonts ? this._shaper(fonts) : null,
200
219
  });
201
- this._layoutOf = { tree, size, color };
220
+ this._layoutOf = { tree, em, color };
202
221
  return this._layout;
203
222
  }
204
223