react-x11 2.51.1 → 2.53.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 (121) hide show
  1. package/README.md +94 -61
  2. package/docs/README.md +256 -0
  3. package/docs/accessibility.md +575 -0
  4. package/docs/appearance.md +317 -0
  5. package/docs/click-to-component.md +92 -0
  6. package/docs/clipboard.md +262 -0
  7. package/docs/components.md +1231 -0
  8. package/docs/dbus.md +205 -0
  9. package/docs/debugging.md +371 -0
  10. package/docs/desktop-calendar.md +247 -0
  11. package/docs/desktop.md +523 -0
  12. package/docs/devtools.md +133 -0
  13. package/docs/drag-and-drop.md +585 -0
  14. package/docs/ecosystem/animation.md +360 -0
  15. package/docs/ecosystem/data-fetching.md +186 -0
  16. package/docs/ecosystem/dev-tooling.md +393 -0
  17. package/docs/ecosystem/forms.md +277 -0
  18. package/docs/ecosystem/headless.md +194 -0
  19. package/docs/ecosystem/i18n.md +86 -0
  20. package/docs/ecosystem/icons.md +377 -0
  21. package/docs/ecosystem/layout.md +206 -0
  22. package/docs/ecosystem/routing.md +135 -0
  23. package/docs/ecosystem/state.md +344 -0
  24. package/docs/ecosystem/testing.md +474 -0
  25. package/docs/ecosystem/theming.md +494 -0
  26. package/docs/ecosystem.md +439 -0
  27. package/docs/elements.md +2079 -0
  28. package/docs/embedding.md +239 -0
  29. package/docs/events.md +742 -0
  30. package/docs/extending.md +1700 -0
  31. package/docs/eyedropper.md +164 -0
  32. package/docs/filedialog.md +222 -0
  33. package/docs/frame.md +436 -0
  34. package/docs/gl.md +173 -0
  35. package/docs/globalmenu.md +195 -0
  36. package/docs/macos.md +592 -0
  37. package/docs/notifications.md +150 -0
  38. package/docs/packaging.md +468 -0
  39. package/docs/permissions.md +143 -0
  40. package/docs/react-features.md +405 -0
  41. package/docs/remote.md +179 -0
  42. package/docs/scale.md +225 -0
  43. package/docs/security.md +132 -0
  44. package/docs/styling.md +1710 -0
  45. package/docs/system.md +519 -0
  46. package/docs/testing.md +494 -0
  47. package/docs/typescript.md +75 -0
  48. package/docs/uri-schemes.md +340 -0
  49. package/docs/wayland-backend.md +332 -0
  50. package/docs/windows-integrations.md +177 -0
  51. package/docs/windows.md +380 -0
  52. package/package.json +11 -3
  53. package/src/Reconciler.js +2 -2
  54. package/src/a11y.js +1 -1
  55. package/src/backend/context2d.js +3 -3
  56. package/src/bootstrap.js +1 -1
  57. package/src/bus.js +39 -26
  58. package/src/cli/create.d.ts +43 -0
  59. package/src/cli/create.js +305 -0
  60. package/src/cli/env.js +484 -0
  61. package/src/cli/index.js +29 -2
  62. package/src/cli/templates/AGENTS.template.md +195 -0
  63. package/src/cli/templates/DESIGN.template.md +269 -0
  64. package/src/cocoa/app.js +6 -6
  65. package/src/cocoa/bezels.js +1 -1
  66. package/src/cocoa/fonts.js +1 -1
  67. package/src/cocoa/globalmenu.js +1 -1
  68. package/src/cocoa/keymap.js +1 -1
  69. package/src/cocoa/panewindow.js +1 -1
  70. package/src/cocoa/player.js +1 -1
  71. package/src/cocoa/presenter.js +2 -2
  72. package/src/cocoa/promotion.js +1 -1
  73. package/src/cocoa/relaunch.js +1 -1
  74. package/src/cocoa/threaded.js +1 -1
  75. package/src/cocoa/video.js +1 -1
  76. package/src/cocoa/window.js +4 -4
  77. package/src/components/Menu.js +7 -2
  78. package/src/components/Switch.js +1 -1
  79. package/src/components/native.js +1 -1
  80. package/src/desktopintegration.js +2 -1
  81. package/src/desktopsettings.js +193 -24
  82. package/src/dnd.js +2 -2
  83. package/src/filedialog.js +3 -3
  84. package/src/glnodes.js +1 -1
  85. package/src/grid.js +1 -1
  86. package/src/keyboardstate.js +28 -0
  87. package/src/layouts.js +1 -1
  88. package/src/node.d.ts +10 -10
  89. package/src/nodes/animation.js +1 -1
  90. package/src/nodes/invalidate.js +1 -1
  91. package/src/nodes/layouthost.js +1 -1
  92. package/src/nodes/video.js +19 -8
  93. package/src/nodes/window/droptarget.js +1 -1
  94. package/src/pacing.js +1 -1
  95. package/src/portal.js +31 -0
  96. package/src/refresh/index.js +2 -1
  97. package/src/screencolor.js +5 -10
  98. package/src/types/system.d.ts +8 -6
  99. package/src/types/video.d.ts +5 -2
  100. package/src/videobackend.js +28 -0
  101. package/src/videoframes.js +29 -16
  102. package/src/wayland/app.js +86 -0
  103. package/src/wayland/clipboard.js +14 -7
  104. package/src/wayland/connection.js +3 -0
  105. package/src/wayland/context2d.js +129 -10
  106. package/src/wayland/protocols/index.json +6 -0
  107. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  108. package/src/wayland/seat.js +28 -1
  109. package/src/wayland/swapchain.js +23 -0
  110. package/src/wayland/video.js +376 -0
  111. package/src/wayland/xkb.js +137 -0
  112. package/src/win32/a11y.js +1 -1
  113. package/src/win32/app.js +2 -2
  114. package/src/win32/fonts.js +1 -1
  115. package/src/win32/glarea.js +4 -4
  116. package/src/win32/overlay.js +1 -1
  117. package/src/win32/panewindow.js +3 -3
  118. package/src/win32/shell.js +1 -1
  119. package/src/win32/window.js +2 -2
  120. package/src/windowid.js +1 -1
  121. package/src/xvideo.js +189 -0
@@ -0,0 +1,1700 @@
1
+ # Extending the element vocabulary
2
+
3
+ `<box>`, `<text>`, `<glarea>` and the rest are built in; `registerElement()`
4
+ adds more from outside the package — a `<sparkline>`, a tray icon, an XEMBED
5
+ `<foreign>` — so a sibling package can ship one without forking react-x11.
6
+
7
+ ```js
8
+ import { registerElement } from 'react-x11/host';
9
+ import { Node } from 'react-x11/node';
10
+
11
+ class SparklineNode extends Node {
12
+ constructor(props, app) {
13
+ super('sparkline', props, app); // the kind must be the element name
14
+ }
15
+
16
+ paint(ctx) {
17
+ super.paint(ctx); // background, border, clip
18
+ const { x, y, width, height } = this.abs;
19
+ const data = this.props.data ?? [];
20
+ const max = Math.max(1, ...data);
21
+ ctx.beginPath();
22
+ data.forEach((value, i) => {
23
+ const px = x + (width * i) / Math.max(1, data.length - 1);
24
+ const py = y + height - (height * value) / max;
25
+ i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
26
+ });
27
+ ctx.strokeStyle = this.props.color ?? '#000';
28
+ ctx.stroke();
29
+ }
30
+ }
31
+
32
+ registerElement('sparkline', {
33
+ create: (props, app) => new SparklineNode(props, app),
34
+ semanticNames: ['data', 'color'],
35
+ });
36
+ ```
37
+
38
+ ```jsx
39
+ <sparkline
40
+ data={[1, 4, 2, 8]}
41
+ color="#c0392b"
42
+ style={{ width: 120, height: 40 }}
43
+ />
44
+ ```
45
+
46
+ Register before you render: the registry is consulted when an element is
47
+ first created, so a late registration only affects trees mounted after it.
48
+
49
+ Design notes (history, measurements, how core serves each hook):
50
+ [design/extending.md](../design/extending.md).
51
+
52
+ ## The definition
53
+
54
+ | field | |
55
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------- |
56
+ | `create(props, app, hostCtx)` | builds and returns the `Node`; `app` is the ntk connection |
57
+ | `drawn` | default `true`: laid out by yoga, painted into the window. `false`: it owns a child X window (below) |
58
+ | `semanticNames` | prop names this element owns even though they are also style names |
59
+ | `selfDamagedProps` | props whose damage your own `applyProps` claims ([Drawing a scene](#drawing-a-scene-into-one-node)) |
60
+ | `childrenAllowed` | default `true`; `false` rejects children by name rather than laying out what never paints |
61
+ | `override` | replace an existing registration. Off by default: two packages claiming one name is a conflict |
62
+
63
+ **`drawn` decides whether your element paints at all.** A kind outside the
64
+ set of drawn kinds lays out, reports a sensible `abs`, and never appears on
65
+ screen, with no error. `registerElement` adds you unless you opt out.
66
+
67
+ **`semanticNames` keeps development and production alike.** In development
68
+ react-x11 throws on `<box width={10} />`, a style property written flat. An
69
+ element whose own props overlap the style vocabulary (`width`, `color`,
70
+ `fontSize`) must list them, or it throws on its own props in development
71
+ only. `<window width>` is the built-in precedent: the X window's width,
72
+ never style. `isStyleProp(name)` from `react-x11/style` says whether a name
73
+ needs declaring.
74
+
75
+ ## Registering under hot reload {#hot-reload}
76
+
77
+ Register at module scope (tree-shaking requires it of a component library).
78
+ That scope re-runs when
79
+ [`react-x11/refresh`](ecosystem/dev-tooling.md#react-refresh) hot-reloads the
80
+ module, so:
81
+
82
+ - **The same `create` reference is never a conflict**: it replaces
83
+ silently, latest flags winning.
84
+ - **During a hot-reload session, any duplicate replaces silently.** The
85
+ session starts at the first hot re-import and lasts for the process.
86
+ Mounted nodes keep the old prototype until they remount (Fast Refresh's
87
+ contract for classes); new mounts get the new definition.
88
+ - **Outside a session, a duplicate throws**; `override: true` says a
89
+ replacement is deliberate.
90
+
91
+ In a hot module, call it through a namespace or default import
92
+ (`Host.registerElement(...)`): a _named_ import called at module top level
93
+ is a transform error there, because named-import bindings initialize a
94
+ microtask after a reload.
95
+
96
+ ## The node contract
97
+
98
+ `Node` implements the whole reconciler-facing surface; an element that only
99
+ draws needs a constructor and a `paint`. What you may override:
100
+
101
+ | method | when |
102
+ | ------------------------------ | ------------------------------------------------------------------------------------------- |
103
+ | `paint(ctx)` | draw. Call `super.paint(ctx)` first (background, border, clip), then draw inside `this.abs` |
104
+ | `paintContent(ctx)` | draw _between_ background and children — where built-ins draw; what a scroller needs |
105
+ | `opaqueRect()` | the rect you cover opaquely every paint, so a pass inside skips the fills under it |
106
+ | `viewportFixedRects()` | what you draw fixed to your scroll pane's viewport, so its blit repaints it |
107
+ | `paintOverhang()` | how far you draw past your box, so claims and culls cover it |
108
+ | `applyProps(next, prev)` | props changed. Call `super`; invalidate anything you derived |
109
+ | `paintChanged(next, prev)` | did anything you draw change? Only if you claim your own damage |
110
+ | `measureContent(constraints)` | your content has a size of its own. Leaves only: no children |
111
+ | `default*(ev)` | your behaviour for a key, a press or focus — what makes it _interactive_ |
112
+ | `hitTest(x, y)` | non-rectangular hit areas. The default walks children in reverse paint order |
113
+ | `insertBefore` / `removeChild` | only if children mean something structural to you |
114
+ | `destroySubtree()` | release what you allocated (pixmaps, fonts, timers). Call `super` |
115
+
116
+ What you read:
117
+
118
+ - `this.props` — the current props, replaced wholesale on update.
119
+ - `this.style` — the flattened `style` with the active `:hover` / `:focus` /
120
+ `:active` / `:disabled` blocks overlaid and `$token`s resolved against the
121
+ theme. **Everything that paints or lays out reads this, never `props`.**
122
+ - `this.abs` — position and size within the owning window, after layout.
123
+ - `this.contentBox()` — `abs` inset by border and padding: where the content
124
+ goes, and where every built-in draws.
125
+ - `this.scale` — device pixels per logical pixel ([scale.md](scale.md)),
126
+ per node (an enclosing `scale` prop zooms a subtree —
127
+ [scale.md](scale.md#a-subtree-of-its-own)), so read it off the node.
128
+ `style`, `abs`, `contentBox()`, the paint context and every rect you hand
129
+ `invalidate`, `scrollContents` or a screen reader are device pixels; a
130
+ synthetic event's `x`/`y` are logical, so hit-test `abs` with
131
+ `ev.nativeEvent.x`/`y`, or multiply. They coincide at 1x, so test with
132
+ `renderX11(element, { scale: 2 })`.
133
+ - `this.resolvedTextStyle()` — this node's text style, ready for
134
+ `app.fonts.layout` (below).
135
+ - `this.resolveColor(value)` — a style colour as a context can paint it: the
136
+ value, or the node's ink for [`currentColor`](styling.md#currentcolor).
137
+ Pass every colour you read off `this.style` through it
138
+ (`ctx.strokeStyle = this.resolveColor(this.style.borderColor)`), or the
139
+ keyword paints nothing. A node whose style paints in `currentColor`
140
+ repaints when its ink moves.
141
+ - `this.direction` — `'ltr'` or `'rtl'`, never `'inherit'`. Yoga has already
142
+ mirrored the boxes; this is for what you draw inside one — which side a
143
+ mark sits on, which way a chevron points, what a pointer coordinate means
144
+ ([styling.md](styling.md#direction-and-the-logical-edges)).
145
+ - `this.root` — the owning `<window>` node; `this.app` — the ntk connection.
146
+ - `this.theme` — the nearest theme at or above this node.
147
+ - `this.paintDamage()` — the rect the current pass covers, or `null` for a
148
+ full one ([below](#drawing-a-scene-into-one-node)).
149
+
150
+ `this.invalidate(layout, damage, reason)` asks for a repaint. Pass the damage
151
+ — `this`, or a tighter rect: an unbounded one repaints the whole window, the
152
+ main performance bug class here ([debugging.md](debugging.md)). `reason` is
153
+ from the closed set the diagnostics print. On a node not yet attached it
154
+ does nothing.
155
+
156
+ `this.focus()` takes the keyboard focus as a click would and returns the
157
+ node; `this.blur()` gives it up; `this.focused` and `this.focusWithin` say
158
+ where it is. A focus change repaints the control and its ring by itself.
159
+
160
+ ### Text of your own
161
+
162
+ An element that draws text — a badge, a code editor, a terminal — shapes it
163
+ through ntk's font manager with the two accessors above:
164
+
165
+ ```js
166
+ class BadgeNode extends Node {
167
+ paint(ctx) {
168
+ super.paint(ctx); // background, border, clip
169
+ const fonts = this.app?.fonts;
170
+ if (!fonts) return; // headless: no font manager, nothing to shape with
171
+ const box = this.contentBox();
172
+ const layout = fonts.layout(
173
+ String(this.props.label ?? ''),
174
+ this.resolvedTextStyle(),
175
+ { maxWidth: box.width || undefined },
176
+ );
177
+ layout.draw(ctx, box.x, box.y);
178
+ }
179
+ }
180
+ ```
181
+
182
+ **Use `contentBox()`, not `abs` minus `style.padding`.** Its insets come from
183
+ layout, with `padding: '10%'`, per-side overrides and border widths
184
+ resolved; a box derived from the style bag matches `<text>`'s only by luck.
185
+
186
+ **Use `resolvedTextStyle()`, not `this.style.fontFamily`.** It layers the
187
+ node's own font properties over what it inherits from the elements around
188
+ it and the palette's default, in the shape `fonts.layout` wants (`family`,
189
+ not `fontFamily`; `variations`, not `fontVariationSettings`; `features`,
190
+ what `fontVariantNumeric` and `fontFeatureSettings` resolve to). Read the
191
+ style bag instead and a `<ThemeProvider>` font or an enclosing box's
192
+ `color` never reaches your element; ask, and it inherits what a `<text>`
193
+ sibling does, a row's `:hover` included
194
+ ([styling.md](styling.md#inheritance-the-ink-the-face-and-the-size)).
195
+
196
+ Read both at paint time: `contentBox()` is computed per call;
197
+ `resolvedTextStyle()` is memoised and dropped by whatever can move it (a
198
+ style or state change, an animation tick, a theme swap, an ancestor's type
199
+ changing). If the text also sizes the element, shape it in `measureContent`
200
+ too. A cache of your own keyed on the type — a shaped run, a rasterized
201
+ label — is dropped in **`_textStyleMoved(cost)`**: `2` when a glyph can
202
+ have moved, `1` when only the ink or glyph rounding did. The default
203
+ re-measures on `2` and repaints on `1`.
204
+
205
+ **Text as pixels.** A GL surface's labels or a signed distance field need
206
+ glyph coverage, not a drawn surface. A layout's **`coverage({ pad })`**
207
+ answers synchronously: one byte per device pixel, the layout box in whole
208
+ pixels with `pad` round it, origin at `(pad, pad)` — linear, grayscale,
209
+ unhinted, as a raster that will be scaled wants.
210
+
211
+ ```js
212
+ const coverage = layout.coverage?.({ pad: 4 }) ?? null;
213
+ if (!coverage) {
214
+ // no answer on this engine: draw onto a Surface and read it back
215
+ }
216
+ ```
217
+
218
+ It is optional — ntk's layout (X11, Wayland, the mock), DirectWrite's and
219
+ CoreText's answer it from the release that added it — so feature-detect and
220
+ keep a readback. It is not `draw` pixel for pixel: `draw` snaps and hints.
221
+
222
+ ### Answering for your own text
223
+
224
+ A `selectable` ancestor lets the user select the text of any descendant
225
+ that answers four questions; there is no registration.
226
+
227
+ ```js
228
+ class LogViewNode extends Node {
229
+ // the string the three below index into — null (the default) means
230
+ // "this element has no text", which is the honest answer for a <box>
231
+ textContent() {
232
+ return this.lines.join('\n');
233
+ }
234
+
235
+ // window coordinates in, a code-point index out. Clamp: a point past the
236
+ // end of the text is the end of the text, which is what a drag into the
237
+ // margin depends on
238
+ textIndexAt(x, y) {
239
+ return this._layout().indexAt(x - this._originX(), y - this._originY());
240
+ }
241
+
242
+ // where a caret at that index stands: a zero-width rect, glyph top to
243
+ // glyph bottom
244
+ textCaretRect(index) {
245
+ const caret = this._layout().caretPosition(index);
246
+ return {
247
+ x: this._originX() + caret.x,
248
+ y: this._originY() + caret.y,
249
+ width: 0,
250
+ height: caret.height,
251
+ };
252
+ }
253
+
254
+ // and the bands a highlight over [start, end) fills
255
+ textRangeRects(start, end) {
256
+ /* one per line — see below */
257
+ }
258
+
259
+ paint(ctx) {
260
+ super.paint(ctx);
261
+ // the range the document selection has claimed of *this* element's
262
+ // text, pushed down whenever it moves. Paint it under the glyphs.
263
+ const range = this.selectionRange;
264
+ if (range) {
265
+ ctx.fillStyle = this.selectionColor;
266
+ const rects = [];
267
+ for (const r of this.textRangeRects(range.start, range.end)) {
268
+ rects.push(r.x, r.y, r.width, r.height);
269
+ }
270
+ ctx.fillRects(rects);
271
+ }
272
+ this._layout().draw(ctx, this._originX(), this._originY());
273
+ }
274
+ }
275
+ ```
276
+
277
+ - **Indices are code points** (ntk's `TextLayout.caretPosition`/`indexAt`:
278
+ an emoji is one position). **Rects are the owning window's device
279
+ pixels**, like `abs`; a mouse event's `x`/`y` are the same in logical
280
+ pixels ([scale.md](scale.md)).
281
+ - **Answer from what you draw**: one layout and origin for painting and all
282
+ four accessors. If the element scrolls, the origin includes the offset.
283
+ - **One band per line, and per direction run.** A selection is contiguous
284
+ in logical order, a line laid out in visual order, so a Latin-to-Arabic
285
+ range covers disjoint stretches: walk ntk's `line.runs` and intersect each
286
+ with the range.
287
+ - **An element that edits owns its selection**: `hasOwnSelection = true` in
288
+ the constructor, and a `selectable` document skips its subtree and its
289
+ presses.
290
+ - **Presses arrive through the base class.** `Node`'s `defaultMouseDown`,
291
+ `defaultMouseDrag`, `defaultMouseUp` and `defaultKeyDown` hand the
292
+ gesture to the nearest `selectable` ancestor. Override one without
293
+ `super` and you take the gesture (right for an editor); to keep both, end
294
+ with `super.defaultMouseDown(ev)`.
295
+
296
+ What a copy assembles, the separators, the keys:
297
+ [elements.md](elements.md#selecting-text).
298
+
299
+ ### A size of your own
300
+
301
+ An element as big as its content — a gauge, a chart, a terminal, an editor
302
+ — implements **`measureContent`**, asked whenever the size on an axis is the
303
+ element's to give:
304
+
305
+ ```js
306
+ const TICK = 30;
307
+
308
+ class GaugeNode extends Node {
309
+ measureContent({ width }) {
310
+ const ticks = Math.max(1, Number(this.props.ticks ?? 4));
311
+ // never wider than the offer, never narrower than one tick
312
+ return { width: Math.max(TICK, Math.min(ticks * TICK, width)), height: 24 };
313
+ }
314
+
315
+ applyProps(next, prev) {
316
+ const before = prev ?? this.props;
317
+ super.applyProps(next, prev);
318
+ // the measurement reads `ticks`, so a new one has to be taken
319
+ if (next.ticks !== before.ticks) this.invalidateMeasure();
320
+ }
321
+ }
322
+ ```
323
+
324
+ `<gauge ticks={6} />` is then 180×24 with no style saying so. It must be a
325
+ **method on the class**: the base constructor wires it to layout, so one
326
+ assigned in your constructor is never asked (`invalidateMeasure()` says so
327
+ in development).
328
+
329
+ #### What it is asked
330
+
331
+ `{ width, height, widthMode, heightMode }`, an unbounded axis as `Infinity`,
332
+ so `Math.min(preferred, width)` is right in every mode; the mode is for when
333
+ it is not:
334
+
335
+ | asked with | the element answers |
336
+ | ---------------- | -------------------------------------------------------------------------------------- |
337
+ | `unconstrained` | its natural size (`<textarea>`: its preferred width, `rows` tall) |
338
+ | `at-most` | its natural size, clamped into the offer |
339
+ | `at-most` with 0 | **the smallest it can be drawn at** — an editor: a character or two; a scroller: small |
340
+ | `exactly` | the style decided; hand the number back and answer for the other axis |
341
+
342
+ The renderer asks `at-most 0` itself, laying the tree out with no room to
343
+ find each leaf's floor: a row too narrow squeezes the element that far and
344
+ no further ([elements.md](elements.md#everything-shrinks-nothing-shrinks-to-nothing)),
345
+ and at the top of the tree it is what `<window minWidth="auto">` sends the
346
+ window manager. Answer 0 and both floors are lost.
347
+
348
+ `at-most` is available room, not a cap: answer larger and the element is
349
+ laid out at that and overflows, as `<text textWrap="nowrap">` does — right
350
+ for content that does not reflow.
351
+
352
+ #### What it must not do
353
+
354
+ It is a question about a hypothetical, asked several times per layout at
355
+ sizes nothing will be drawn at:
356
+
357
+ - **do not read `this.abs`** — it is last frame's;
358
+ - **do not paint, invalidate, or set state** — cache expensive work (as
359
+ `<text>` memoizes its layout per width), but answer as a pure function of
360
+ content and constraints;
361
+ - **answer finite numbers** — `{ width: 0, height: 0 }` for content not yet
362
+ arrived; anything else throws naming the element.
363
+
364
+ An element that measures itself **cannot have children**; rendering one
365
+ inside it is an error naming both.
366
+
367
+ #### Saying the answer moved
368
+
369
+ Layout caches the measurement. Call `invalidateMeasure(reason?)` when an
370
+ input it reads changes — a prop, arriving data, a loaded font — and the next
371
+ frame asks again; nothing else knows your inputs. `reason` is from the
372
+ diagnostics' closed set ([debugging.md](debugging.md#invalidation-reasons)),
373
+ default `'measure'`.
374
+
375
+ #### Content with an aspect ratio
376
+
377
+ `intrinsicSize` is the `<image>`/`<svg>` measurement, a natural size whose
378
+ proportions are kept:
379
+
380
+ ```js
381
+ import { Node, intrinsicSize } from 'react-x11/node';
382
+
383
+ class ThumbNode extends Node {
384
+ measureContent(constraints) {
385
+ return intrinsicSize(this.decoded ?? { width: 0, height: 0 }, constraints);
386
+ }
387
+ }
388
+ ```
389
+
390
+ It shrinks to the width on offer, the height following, and scales the
391
+ width from a style height when only that is fixed, as `<img>` does.
392
+
393
+ ### Behaviour of your own
394
+
395
+ An element that does something — an editor, a terminal, a table with cell
396
+ editing, a tree with type-ahead — implements **default actions**, the seam
397
+ `<textinput>` runs on. Order:
398
+
399
+ 1. the app's `onKeyDown` / `onMouseDown` / … handlers, capture then bubble;
400
+ 2. **unless one called `ev.preventDefault()`**, the element's `default*`
401
+ method.
402
+
403
+ So an app can veto or precede the behaviour without knowing how the element
404
+ is built — the reason to put it here rather than in a wrapping component.
405
+
406
+ | method | when |
407
+ | ------------------------ | ------------------------------------------------------------------------------------------------ |
408
+ | `defaultKeyDown(ev)` | a key, at the focused node. `ev.keysym`: the Latin `XK_*`, any layout; `codepoint >= 0x20`: text |
409
+ | `defaultKeyUp(ev)` | the key was released. Rare: for answering the whole keystroke, as `<foreign>` does |
410
+ | `defaultComposition(ev)` | a dead key or Compose in progress: `ev.type` the phase, `ev.data` what to show or insert |
411
+ | `defaultMouseDown(ev)` | a press: place a caret, grab a handle; `ev.capturePointer()` keeps the gesture |
412
+ | `defaultMouseDrag(ev)` | motion while _this_ element holds the press, even outside its box |
413
+ | `defaultMouseUp(ev)` | that press was released |
414
+ | `defaultMouseMove(ev)` | pointer motion over it, no button down. The first is the enter |
415
+ | `defaultMouseLeave(ev)` | the pointer left: unlight it |
416
+ | `defaultWheel(ev)` | the wheel, before the scroll chain, for a wheel that is not a scroll |
417
+ | `defaultContextMenu(ev)` | right-click, after the press (preventing it keeps the caret): open your menu |
418
+ | `defaultFocus(info)` | it, or its window, got focus. `info`: `{ reason, backwards }`; `'key'` + direction is Tab |
419
+ | `defaultBlur()` | focus left, or the window lost it: stop the caret |
420
+
421
+ ```js
422
+ import { Node, CARET_BLINK_MS } from 'react-x11/node';
423
+ import { XK_TAB, XK_ESCAPE } from 'react-x11/keysyms';
424
+
425
+ class EditorNode extends Node {
426
+ constructor(props, app) {
427
+ super('codeeditor', props, app);
428
+ // without this nothing focuses it, and no key ever arrives
429
+ this.focusableByDefault = true;
430
+ this.defaultCursor = 'text';
431
+ }
432
+
433
+ defaultKeyDown(ev) {
434
+ if (ev.keysym === XK_ESCAPE) {
435
+ // the way out: the next Tab leaves, so the element is never a trap
436
+ this._tabEscapes = true;
437
+ return;
438
+ }
439
+ if (ev.keysym === XK_TAB) {
440
+ if (this._tabEscapes) {
441
+ this._tabEscapes = false; // one Tab, then it indents again
442
+ return; // …and this one belongs to focus traversal
443
+ }
444
+ this.indent(ev.shiftKey ? -1 : 1);
445
+ ev.preventDefault(); // this one is mine
446
+ return;
447
+ }
448
+ this._tabEscapes = false;
449
+ if (ev.codepoint >= 0x20) this.insert(ev.key);
450
+ }
451
+
452
+ defaultFocus() {
453
+ this._caretOn = true;
454
+ this._blink = setInterval(() => {
455
+ this._caretOn = !this._caretOn;
456
+ this.root?.invalidate(false, this, 'caret');
457
+ }, CARET_BLINK_MS);
458
+ this._blink.unref?.();
459
+ }
460
+
461
+ defaultBlur() {
462
+ clearInterval(this._blink);
463
+ this._blink = null;
464
+ this._caretOn = false;
465
+ }
466
+ }
467
+ ```
468
+
469
+ - **A gesture is vetoed once, at its press**: a prevented press means no
470
+ `defaultMouseDown`, `defaultMouseDrag` or `defaultMouseUp`.
471
+ - **Hover is motion.** `defaultMouseMove` runs for the element under the
472
+ pointer, coalesced to once per frame, and `defaultMouseLeave` when it
473
+ goes; the first motion is the enter (there is no `defaultMouseEnter`).
474
+ A capture freezes hover and sends motion to `defaultMouseDrag`. A node
475
+ unmounted while hovered is forgotten, not left: release in
476
+ `destroySubtree` what the highlight holds.
477
+ - **The cursor is the point's.** `defaultCursor` covers the element;
478
+ `cursorAt(x, y)`, asked on every motion in device pixels like `abs`,
479
+ answers one per part (a link, a handle, text), or null to fall through to
480
+ `defaultCursor`, then the parent. A `cursor` style still wins; a capture
481
+ freezes it.
482
+
483
+ ```js
484
+ cursorAt(x, y) {
485
+ return this.linkAt(x - this.abs.x, y - this.abs.y) ? 'pointer' : 'text';
486
+ }
487
+ ```
488
+
489
+ - **A wheel that is not a scroll** — a zoom about the pointer — needs the
490
+ point (`ev.x`/`ev.y`) and the modifier, not just the deltas
491
+ `canScroll`/`scrollBy` get ([below](#scrolling-content-you-painted)).
492
+ `defaultWheel(ev)` gets the whole event at the node under the pointer
493
+ before the scroll chain walks; `ev.preventDefault()` stops the walk. Its
494
+ deltas keep their fractions.
495
+
496
+ ```js
497
+ defaultWheel(ev) {
498
+ if (!ev.ctrlKey) return; // an unmodified wheel still scrolls
499
+ this.zoomAbout(ev.x, ev.y, Math.exp(-ev.deltaY / 400));
500
+ ev.preventDefault(); // …and this one was mine
501
+ }
502
+ ```
503
+
504
+ - **Focus is a prerequisite.** Keys go to the focused node, and only an
505
+ element with `focusableByDefault = true` is focused (a tab stop and press
506
+ target, like `<textinput>`); the app's `focusable={false}` or `tabIndex`
507
+ still overrides it ([events.md](events.md#focus)).
508
+ - **Tab is an ordinary key**: it reaches `defaultKeyDown` before focus
509
+ traversal, and `ev.preventDefault()` there keeps focus where it is.
510
+ - **`super.defaultKeyDown(ev)`** is the text-selection keys, and Space/Enter
511
+ dispatching `onClick` ([events.md](events.md#space-and-enter-are-a-click)).
512
+ An editor that answers every key (Space types a space) skips it, as
513
+ `<textinput>` does; one answering a few keys ends with
514
+ `return super.defaultKeyDown(ev)`, as the scroll pane does.
515
+ - **An element that eats Tab owes a way out.** The convention, shown above:
516
+ **Escape arms one pass-through Tab** — the next Tab leaves, the one after
517
+ indents again — so no keyboard or screen-reader user is trapped.
518
+ - **An element that types text implements `defaultComposition`.** Dead keys
519
+ and Compose sequences never reach `defaultKeyDown`, so without it `é`
520
+ types as `e` ([events.md](events.md#composition)). Only the last phase
521
+ inserts:
522
+
523
+ ```js
524
+ defaultComposition(ev) {
525
+ if (ev.type === 'compositionEnd') {
526
+ this.preedit = '';
527
+ if (ev.data) this.insert(ev.data); // one edit, one undo entry
528
+ } else {
529
+ this.preedit = ev.data; // draw it at the caret, underlined
530
+ }
531
+ this.root?.invalidate(false, this, 'text');
532
+ }
533
+ ```
534
+
535
+ The preedit is not part of your value: it never reaches `onChange` or the
536
+ undo history, a controlled value rewritten mid-composition cannot corrupt
537
+ it, and abandoning it (Escape, blur) drops a string, not an edit. An
538
+ element holding no text can leave composition out; one that **forwards keys
539
+ to another program** (`<foreign>`) sets `composes = false` so the dead key
540
+ reaches the client's input method.
541
+
542
+ **A caret blinks at `CARET_BLINK_MS`**, `<textinput>`'s cadence, so carets
543
+ blink in step. Stop the timer in `defaultBlur` _and_ `destroySubtree`: a
544
+ node unmounted while focused is never blurred.
545
+
546
+ ### Text a screen reader can read
547
+
548
+ To an assistive technology, an element that draws its own text is a
549
+ rectangle with no characters, caret or selection. `a11yTextState()` gives it
550
+ what `<textinput>` has, through the same code: AT-SPI's `Text` (characters,
551
+ words, lines, caret, selection, and the `text-changed` / `text-caret-moved`
552
+ / `text-selection-changed` events Orca narrates) and, for an editor,
553
+ `EditableText`.
554
+
555
+ ```js
556
+ class EditorNode extends Node {
557
+ a11yTextState() {
558
+ return {
559
+ value: this.lines.join('\n'), // what is drawn
560
+ caret: this.caret, // code points, into `value`
561
+ selectionStart: this.anchor,
562
+ selectionEnd: this.caret,
563
+ editable: true,
564
+ multiline: true,
565
+ };
566
+ }
567
+
568
+ insert(text) {
569
+ // …the element's own edit…
570
+ this.notifyA11yTextChanged();
571
+ }
572
+ }
573
+ ```
574
+
575
+ | member | when |
576
+ | ----------------------------------- | ---------------------------------------------------------------------------------- |
577
+ | `a11yTextState()` | always, for an element holding text; `null` if none |
578
+ | `notifyA11yTextChanged()` | every edit, caret move, selection or composition change; state is then _pulled_ |
579
+ | `a11ySetSelection(start, end)` | an AT moved the caret (to `end`) or selected. Without it: read, not navigated |
580
+ | `a11yReplaceText(start, end, text)` | an AT inserted, deleted or set the lot. Without it: read, not typed in |
581
+ | `a11yRole` | the ARIA role if the app sets none (`'textbox'`, `'log'`…); set in the constructor |
582
+
583
+ - **Viewer or editor.** A viewer (a document, a code block, a log, a
584
+ terminal) reports `value` and a selection: it gets `Text`, a `document`
585
+ role, and a selection an AT can read and set. An editor adds
586
+ `editable: true` and `a11yReplaceText` for the EDITABLE state and
587
+ `EditableText`; `editable` without `a11yReplaceText` is announced as
588
+ editable but not exposed as typeable.
589
+ - **Offsets are code points into what you draw** (`Array.from(value)`), an
590
+ open composition included, since extents and magnifiers follow the
591
+ glyphs. Mark it with `preedit: { offset, text }` and its churn is reported
592
+ as text the user did not type
593
+ ([accessibility.md](accessibility.md#while-a-composition-is-open)).
594
+ Offsets you report are clamped.
595
+ - **`notifyA11yTextChanged()` is free when nobody listens**: call it on
596
+ every path that moves the text.
597
+ - **`a11yTextState()` runs several times per change**: answer from held
598
+ state; never shape, copy a buffer, or invalidate.
599
+ - **Say `multiline`**, or neither state is claimed (a guess from the value
600
+ would flip the first time someone pressed Enter).
601
+
602
+ `renderX11(el, { a11y: true })` returns a spy recording the same feed
603
+ ([accessibility.md](accessibility.md#asserting-what-a-screen-reader-would-hear)).
604
+
605
+ ### A scene a screen reader can walk
606
+
607
+ An element that draws many interactive things is one node, so one
608
+ accessible: a graph pane reads as **"Flow graph, group"**, nothing in it
609
+ findable or actionable, whatever `role`/`aria-*` it carries. Describe them:
610
+
611
+ ```js
612
+ class FlowNode extends Node {
613
+ a11yScene() {
614
+ return this.nodes.map((node) => ({
615
+ id: node.id, // stable for as long as it is on screen
616
+ role: 'listitem',
617
+ name: node.label,
618
+ rect: node.rect, // window coordinates, like `abs`
619
+ states: {
620
+ selected: this.selection.has(node.id),
621
+ focused: this.cursor === node.id,
622
+ },
623
+ }));
624
+ }
625
+
626
+ select(id) {
627
+ // …the element's own selection…
628
+ this.notifyA11ySceneChanged();
629
+ }
630
+ }
631
+ ```
632
+
633
+ Each item becomes a real child accessible — named, placed, focusable,
634
+ `selected` announced — read as `<box role="listitem" aria-selected>` would
635
+ be. `id` keeps a child the same child across frames, so a screen reader's
636
+ references survive a scene rebuilt every frame.
637
+
638
+ | member | when |
639
+ | ----------------------------- | ---------------------------------------------------------------------------------------------------- |
640
+ | `a11yScene()` | for an element drawing interactive things. Called per question: keep it cheap |
641
+ | `notifyA11ySceneChanged()` | the scene moved between commits (a drag, an animation, your arrow keys); a commit re-reads it itself |
642
+ | `a11ySceneAction(id, action)` | an AT acted on one. Return `true` to claim it, else core answers |
643
+
644
+ What one item may say:
645
+
646
+ | field | |
647
+ | ---------------------- | ------------------------------------------------------------------------------------------- |
648
+ | `id` | required. Its name while it is drawn, matched within its parent |
649
+ | `rect` | required. Window coordinates: where AT focus is drawn, what a magnifier follows |
650
+ | `role` | an ARIA role; `'group'` if none |
651
+ | `name` / `description` | what a screen reader says; an unnamed item announces as having none |
652
+ | `states` | `selected`, `checked`, `expanded`, `disabled`, `busy`; `focused` for your keyboard cursor |
653
+ | `focusable` | default true |
654
+ | `props` | other ARIA: `aria-posinset`/`aria-setsize` (Orca's "3 of 7"), `aria-level`, `aria-valuenow` |
655
+ | `children` | structure: series and points, groups and nodes |
656
+
657
+ - **`focused` is the only way your cursor is reported**: the focus manager
658
+ holds your element, not the item your arrow keys move. Navigation stays
659
+ yours.
660
+ - **Actions fall back.** Without `a11ySceneAction`, an activation is a
661
+ synthetic click at the item's `rect`, as a mouse user's would be; `focus`
662
+ focuses the element; `scroll` reveals the item. Claim with `true` only
663
+ what differs — a non-click activation, a cursor move, your own viewport.
664
+ - **An action needs a role that promises one** (`button`, `option`,
665
+ `treeitem`, `checkbox`) or your `a11ySceneAction`; a bare `listitem` is
666
+ read, not clicked.
667
+
668
+ The same spy records an item's states changing as it does a `<Checkbox>`'s.
669
+
670
+ ### The standard edit menu
671
+
672
+ An element that edits or selects text of its own gets `<textinput>`'s
673
+ right-click Undo / Cut / Copy / Paste / Select All menu — the same one — by
674
+ naming its verbs:
675
+
676
+ ```js
677
+ import { openEditMenu } from 'react-x11';
678
+
679
+ class EditorNode extends Node {
680
+ defaultContextMenu(ev) {
681
+ openEditMenu(
682
+ this,
683
+ { x: ev.x, y: ev.y },
684
+ {
685
+ canUndo: this.canUndo,
686
+ undo: () => this.undo(),
687
+ canRedo: this.canRedo,
688
+ redo: () => this.redo(),
689
+ hasSelection: !this.selection.isEmpty(),
690
+ cut: () => this.cut(),
691
+ copy: () => this.copy(),
692
+ paste: () => this.paste(),
693
+ selectAll: () => this.selectAll(),
694
+ },
695
+ );
696
+ }
697
+ }
698
+ ```
699
+
700
+ Core supplies the rows and order, enablement, Paste following **selection
701
+ ownership** rather than asking the server
702
+ ([elements.md](elements.md#the-right-click-menu)), the keys, the dismissing
703
+ grab, and handing the keyboard back.
704
+
705
+ **A verb you leave out is a row that is not there**, not a greyed one. A
706
+ read-only surface passes three things for a two-row menu; no verbs, no
707
+ menu:
708
+
709
+ ```js
710
+ openEditMenu(
711
+ node,
712
+ { x, y },
713
+ {
714
+ hasSelection: selection.hasSelection(),
715
+ copy,
716
+ selectAll,
717
+ },
718
+ );
719
+ ```
720
+
721
+ **Enablement is core's.** Answer `canUndo`, `canRedo` and `canSelectAll`
722
+ (default true; false when empty or all selected) and hand over
723
+ `hasSelection`; Cut and Copy follow the selection, Paste what the server
724
+ said about the clipboard. `at` is the pointer in the **owner window's**
725
+ coordinates (a synthetic event's `x`/`y`); the menu is clamped into its
726
+ monitor.
727
+
728
+ | function | |
729
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------ |
730
+ | `editMenuOpen(node)` | is it up? (it holds the keyboard; keep your selection lit) |
731
+ | `closeEditMenu(node)` | close it if content changed under it or your surface scrolled (a choice, Escape, a press outside close it already) |
732
+
733
+ All three come from the **package root**, so a component wrapping an
734
+ element can open the menu from `onContextMenu` and a ref.
735
+
736
+ ### Scrolling content you painted
737
+
738
+ `<box overflow="scroll">` scrolls children. An editor, a terminal or a
739
+ canvas-backed table scrolls pixels it draws, and joins the same machinery
740
+ with:
741
+
742
+ | method | |
743
+ | ------------------------ | --------------------------------------------------------------------------------- |
744
+ | `canScroll(dx, dy)` | is there room on the axis this delta names? The wheel asks first |
745
+ | `scrollBy(by)` | `scrollBy(dy)` or `scrollBy({x, y})`: move by that much |
746
+ | `measureScrollContent()` | `Scrollable` only: `{ width, height }` the content reaches. Default: the children |
747
+
748
+ **The wheel's default action is the first two.** It walks out from the hit
749
+ node asking `canScroll(deltaX, deltaY)`; the first yes gets `scrollBy`, up
750
+ to the `<window>`. Say no when there is nothing to move and the wheel
751
+ chains to what is behind you, as in a browser (`<textarea>` does). Answer
752
+ from the **extent, not the position**: a viewport at its bottom keeps the
753
+ rest of a flick. The hit node's `defaultWheel`
754
+ ([above](#behaviour-of-your-own)) runs before the walk.
755
+
756
+ Deltas are **whole pixels**: a touchpad's fractions stay on `ev.deltaY`
757
+ ([events.md](events.md#wheel)), and the default action carries them to the
758
+ next event, so a scroll can always be a blit and a slow one still moves.
759
+
760
+ #### The mixin, for everything else
761
+
762
+ `Scrollable(Node)` adds the offsets, `scrollTo`/`scrollBy`/`scrollIntoView`,
763
+ drawn scrollbars and their drags, the scroll keys (arrows, Page, Home/End,
764
+ Space), the tab stop and the AT-SPI scroll-pane role, all reading one
765
+ number pair:
766
+
767
+ ```js
768
+ import { Node, Scrollable } from 'react-x11/node';
769
+
770
+ const LINE = 16;
771
+
772
+ class EditorNode extends Scrollable(Node) {
773
+ constructor(props, app) {
774
+ super('codeeditor', props, app);
775
+ }
776
+
777
+ // scrolling is what this element *is*, rather than something an app opts
778
+ // into with a style; the default answers `overflow: 'scroll'`
779
+ isScroller() {
780
+ return true;
781
+ }
782
+
783
+ // …and the one thing only this element knows: how far the drawing goes
784
+ measureScrollContent() {
785
+ return {
786
+ width: this.longestLineWidth(),
787
+ height: LINE * this.lines.length,
788
+ };
789
+ }
790
+
791
+ paintContent(ctx) {
792
+ const box = this.contentBox();
793
+ ctx.save();
794
+ ctx.beginPath();
795
+ ctx.rect(box.x, box.y, box.width, box.height);
796
+ ctx.clip();
797
+ this.drawLines(ctx, box.x - this.scrollX, box.y - this.scrollY);
798
+ ctx.restore();
799
+ }
800
+ }
801
+ ```
802
+
803
+ - **Draw in `paintContent`, not `paint`.** `paint` draws background,
804
+ `paintContent`, children, border and last a `Scrollable`'s scrollbars;
805
+ drawing after `super.paint(ctx)` covers your own thumb.
806
+ - **Offset and clip yourself**: clip to `contentBox()` and subtract
807
+ `scrollX`/`scrollY`, already clamped to your extent. `scrollX` counts from
808
+ the edge the content starts at — the right one under `direction: 'rtl'`
809
+ ([styling.md](styling.md#direction-and-the-logical-edges)) — as does your
810
+ reported `width`, so drawing in your own direction needs no branch.
811
+ - **Do not assign `focusableByDefault`**: `Scrollable` answers it (a tab stop
812
+ when there is somewhere to go) and assigning throws. Override the getter.
813
+ - **`measureScrollContent` runs at most once per layout pass**, and is
814
+ reused when nothing inside was laid out again (a pure scroll). It may read
815
+ layout geometry; it must not paint or invalidate, and must return finite
816
+ numbers or it throws, naming the element.
817
+ - **When your extent changes** (a typed line, arriving rows), call
818
+ `this.invalidate(true, this, 'scroll')`, or scrolling keeps the stale one.
819
+
820
+ #### Scrolling the pixels, not just the offset
821
+
822
+ `scrollBy` moves a number; `paintContent` redraws at the new offset. A
823
+ drawing that is expensive and kept in a retained buffer — a terminal grid, a
824
+ log, a minimap, a panning chart — can shift its surviving band inside a
825
+ `Surface` instead:
826
+
827
+ ```js
828
+ import { Node, Scrollable } from 'react-x11/node';
829
+ import { Surface } from 'react-x11/ntk';
830
+
831
+ class GridNode extends Scrollable(Node) {
832
+ ensureSurface(width, height) {
833
+ if (this.surface?.width === width && this.surface?.height === height)
834
+ return;
835
+ this.surface?.destroy();
836
+ this.surface = new Surface(this.app, { width, height });
837
+ this.drawRows(this.visibleRows()); // a new buffer starts transparent
838
+ }
839
+
840
+ scrollBy(by) {
841
+ const from = this.scrollY;
842
+ super.scrollBy(by);
843
+ // ask *after*: the scroller clamped it to the extent you reported
844
+ const dy = from - this.scrollY;
845
+ if (!dy || !this.surface) return;
846
+ const all = {
847
+ x: 0,
848
+ y: 0,
849
+ width: this.surface.width,
850
+ height: this.surface.height,
851
+ };
852
+ if (this.surface.copyWithin(all, 0, dy)) this.drawRows(this.exposedBy(dy));
853
+ else this.drawRows(this.visibleRows());
854
+ }
855
+
856
+ paintContent(ctx) {
857
+ const box = this.contentBox();
858
+ ctx.drawImage(this.surface, box.x, box.y);
859
+ }
860
+
861
+ destroySubtree() {
862
+ this.surface?.destroy();
863
+ super.destroySubtree();
864
+ }
865
+ }
866
+ ```
867
+
868
+ `copyWithin(src, dx, dy)` shifts `src` (`{x, y, width, height}`, surface
869
+ coordinates) in place by whole pixels, overlap-safe, and returns **whether
870
+ anything survived**; `false` (a fresh buffer, a far jump, a resize) means
871
+ repaint the lot.
872
+
873
+ `Surface` from `react-x11/ntk` is the backend's own — ntk's pixmap on X11,
874
+ a CG bitmap on Cocoa, a GL render target on Wayland, a Direct2D bitmap on
875
+ win32 — so the element runs unchanged; on win32 `copyWithin` returns
876
+ `false` for now. Where a surface has one graphics state (a bitmap, a render
877
+ target), `getContext('2d')` returns the same context each time and its
878
+ `destroy()` is a no-op.
879
+
880
+ **Hold one context**, taken once with `getContext('2d')`: on every backend,
881
+ shared GL devices included, its draws land in its own surface even
882
+ mid-paint.
883
+
884
+ ```js
885
+ drawRows(rows) {
886
+ const ctx = (this.ctx ??= this.surface.getContext('2d'));
887
+ for (const row of rows) this.paintRow(ctx, row);
888
+ }
889
+ ```
890
+
891
+ `surface.render(fn)` is the scoped alternative for a whole-surface repaint:
892
+ a clean transform and clip, flushed when `fn` returns. It is the same
893
+ context, so it resets a held context's transform.
894
+
895
+ **An opaque surface composites with `copy`**, skipping the blend:
896
+
897
+ ```js
898
+ paintContent(ctx) {
899
+ const box = this.contentBox();
900
+ ctx.globalCompositeOperation = 'copy';
901
+ ctx.drawImage(this.surface, box.x, box.y);
902
+ ctx.globalCompositeOperation = 'source-over';
903
+ }
904
+ ```
905
+
906
+ Set it back (or `save`/`restore`): it is state on the window's context. Use
907
+ it only where every pixel is opaque. A backend that cannot draw it refuses
908
+ the assignment; read it back to know.
909
+
910
+ `<box overflow="scroll">` blits its own viewport on a pure scroll; an
911
+ element that draws live asks for that with
912
+ [`scrollContents`](#panning-a-scene-you-drew).
913
+
914
+ #### Drawing what stays put while the pane scrolls
915
+
916
+ The scroll blit copies a pane's whole viewport. An element that paints part
917
+ of itself against that viewport — a fixed background, a `position: fixed`
918
+ header in a document it draws — names it in `viewportFixedRects()` (window
919
+ coordinates, device pixels; default `null`), and a blitting scroll
920
+ repaints those rects where they are and where the copy dragged them:
921
+
922
+ ```js
923
+ class DocumentNode extends Node {
924
+ _viewport() {
925
+ // the nearest scroll pane: the one whose scroll the answer is about
926
+ let pane = this.parent;
927
+ while (pane && !pane.isWindow && !pane.isScroller()) pane = pane.parent;
928
+ return pane && !pane.isWindow ? pane.contentBox() : null;
929
+ }
930
+
931
+ viewportFixedRects() {
932
+ const viewport = this._viewport();
933
+ return viewport && [{ ...viewport, height: HEADER }];
934
+ }
935
+
936
+ paintContent(ctx) {
937
+ // …the document, at `this.abs`, and the header over it where the
938
+ // viewport is, from the same geometry the answer came from
939
+ }
940
+ }
941
+ ```
942
+
943
+ Rects covering most of the viewport make the scroll a repaint. Only the
944
+ nearest scroll pane asks (an outer pane moves the inner one with what is
945
+ fixed to it), after layout, on each scroll it blits.
946
+
947
+ #### Keeping what the viewport shows while your content moves
948
+
949
+ Laid out at a new width, a document rewraps above the fold and the
950
+ paragraph at the top of the viewport moves. `anchorScrollBy(dx, dy)` keeps
951
+ it on screen, as CSS Scroll Anchoring does: your content moved (dx, dy)
952
+ device pixels in this layout pass, and the nearest scroll pane shifts its
953
+ offsets by as much before placing it, in the same frame.
954
+
955
+ ```js
956
+ class DocumentNode extends Node {
957
+ measureContent({ width }) {
958
+ // what is at the top of the viewport, before and after
959
+ const anchor = this._blockAtViewportTop();
960
+ const was = anchor?.top;
961
+ this._layOut(width);
962
+ if (anchor) this.anchorScrollBy(0, anchor.top - was);
963
+ return { width, height: this._height };
964
+ }
965
+ }
966
+ ```
967
+
968
+ Call it in the layout pass. Shifts in one pass add up; one outside a pass
969
+ waits for, and requests, the next. An app `scrollTo` since the last pass
970
+ drops it; a `scrollIntoView` lands after it; the result is clamped. It
971
+ returns false where nothing scrolls the element.
972
+
973
+ ### Drawing a scene into one node
974
+
975
+ A frame repaints damaged **rects**, each in a clipped pass that skips
976
+ subtrees outside it — at node granularity. When your element is the scene (a
977
+ graph, a chart, a timeline, a code editor), a small rect still redraws all
978
+ of it into the clip. Two opt-in accessors fix that; say nothing and core's
979
+ answer stands.
980
+
981
+ **Painting: `paintDamage()`** — this pass's rect (window coordinates,
982
+ device pixels), or `null` when everything must be drawn. Cull with a pixel
983
+ of slack, as core does: antialiasing inks just past computed bounds, and
984
+ the pass is clipped anyway. `overlaps(a, b, 1)` here asks whether two rects
985
+ come within a pixel:
986
+
987
+ ```js
988
+ class FlowNode extends Node {
989
+ paintContent(ctx) {
990
+ // null outside a paint and on a full pass, and both mean the same
991
+ // thing: nothing bounds you, draw the lot
992
+ const damage = this.paintDamage();
993
+ for (const cell of this.gridCells()) {
994
+ if (damage && !overlaps(cell, damage, 1)) continue;
995
+ this.drawCell(ctx, cell);
996
+ }
997
+ for (const edge of this.edges) {
998
+ if (damage && !overlaps(edge.routedBounds, damage, 1)) continue;
999
+ this.drawEdge(ctx, edge);
1000
+ }
1001
+ // …nodes, minimap, controls
1002
+ }
1003
+ }
1004
+ ```
1005
+
1006
+ **Committing: `selfDamagedProps`.** A new identity for any non-event prop
1007
+ damages the whole node, so a controlled scene committing a new `nodes`
1008
+ array per drag step repaints the pane. Name the props whose damage your
1009
+ `applyProps` claims, and the commit claims none:
1010
+
1011
+ ```js
1012
+ registerElement('flow', {
1013
+ create: (props, app) => new FlowNode(props, app),
1014
+ semanticNames: ['nodes', 'edges'],
1015
+ // "my applyProps diffs these and invalidates what actually moved"
1016
+ selfDamagedProps: ['nodes', 'edges'],
1017
+ });
1018
+ ```
1019
+
1020
+ ```js
1021
+ class FlowNode extends Node {
1022
+ applyProps(next, prev) {
1023
+ const before = prev ?? this.props;
1024
+ super.applyProps(next, prev); // claims nothing for `nodes`
1025
+ if (next.nodes !== before.nodes) {
1026
+ // …so this is the only claim, and it is the box that moved
1027
+ this.invalidate(
1028
+ false,
1029
+ this.movedBounds(next.nodes, before.nodes),
1030
+ 'props',
1031
+ );
1032
+ }
1033
+ }
1034
+ }
1035
+ ```
1036
+
1037
+ To decide on values rather than names, override
1038
+ **`paintChanged(next, prev)`**, which the list feeds:
1039
+
1040
+ ```js
1041
+ paintChanged(next, prev) {
1042
+ if (next.nodes !== prev.nodes && onlyPositionsMoved(next.nodes, prev.nodes)) {
1043
+ return false; // folded in place above, and the moved box is claimed
1044
+ }
1045
+ return super.paintChanged(next, prev);
1046
+ }
1047
+ ```
1048
+
1049
+ - **Hand what you do not recognise to `super`**, which is conservative, or
1050
+ a prop you never thought of (`aria-label`, `title`) leaves stale pixels.
1051
+ - **A style change never reaches either seam**: core compares `style` by
1052
+ value and repaints what `Node.paint` draws.
1053
+ - **Claim before you skip**: `selfDamagedProps` claims nothing for you; a
1054
+ named prop you forget to invalidate renders nothing.
1055
+ - **Not inside `paintCached`**
1056
+ ([below](#drawing-once-instead-of-every-frame)): a copy culled by window
1057
+ damage is cached half-drawn.
1058
+
1059
+ The accessible tree has the same one-node problem: [A scene a screen reader
1060
+ can walk](#a-scene-a-screen-reader-can-walk). On a 300-node `<Flow>` the
1061
+ seams take a drag step from about 150 ms to 30 ms.
1062
+ `REACT_X11_DEBUG_PAINT=1` strokes each pass's rect, so a scene still
1063
+ repainting whole strobes across the pane ([debugging.md](debugging.md)).
1064
+
1065
+ #### Panning a scene you drew
1066
+
1067
+ A pan moves every pixel of a picture already on screen. `scrollContents`
1068
+ does for your viewport what `<box overflow="scroll">` does: blit the
1069
+ surviving band, repaint the exposed strip.
1070
+
1071
+ ```js
1072
+ class FlowNode extends Node {
1073
+ onDragPan(dx, dy) {
1074
+ this.panX += dx;
1075
+ this.panY += dy;
1076
+ // "the pixels in here moved by (dx, dy); the rest of it is new"
1077
+ this.scrollContents(this.contentBox(), dx, dy);
1078
+ }
1079
+
1080
+ paintContent(ctx) {
1081
+ // …and this is the exposed strip, not the pane. Nothing here changes.
1082
+ const damage = this.paintDamage();
1083
+ for (const item of this.scene()) {
1084
+ if (damage && !overlaps(item.bounds, damage, 1)) continue;
1085
+ this.draw(ctx, item);
1086
+ }
1087
+ }
1088
+ }
1089
+ ```
1090
+
1091
+ The call claims `rect` and arms a blit; at frame time core shifts the band
1092
+ and **narrows the claim to the exposed strip**, which `paintDamage()` hands
1093
+ your paint. If anything declines, `rect` is repainted as without the call;
1094
+ the element never needs to know which.
1095
+
1096
+ - **`dx`/`dy` are how far the pixels moved** (`copyWithin`'s sense, not a
1097
+ scroll offset's): panning right by ten is `dx: 10`, exposing the left
1098
+ edge. Whole device pixels; `rect` in window coordinates, inside your
1099
+ node. Diagonal is fine.
1100
+ - **You promise that inside `rect` the frame is exactly that translation.**
1101
+ Core declines on the rest: a claim from elsewhere reaching into `rect` by
1102
+ even a pixel, a sibling drawing over it, a child laid out over it, your
1103
+ own border ring or rounded corner, a layout change, a region too small or
1104
+ a shift too large. Pans in one frame coalesce by net shift; two regions
1105
+ of one node fall back.
1106
+
1107
+ **Furniture along an edge** — a HUD strip — is carved out of the region and
1108
+ claimed beside it; sibling overlay panels outside `rect` work the same way:
1109
+
1110
+ ```js
1111
+ onDragPan(dx, dy) {
1112
+ this.panX += dx;
1113
+ this.panY += dy;
1114
+ const box = this.contentBox();
1115
+ const hud = { ...box, y: box.y + box.height - HUD, height: HUD };
1116
+ this.scrollContents({ ...box, height: box.height - HUD }, dx, dy);
1117
+ this.invalidate(false, hud, 'props'); // repainted, not shifted
1118
+ }
1119
+ ```
1120
+
1121
+ **Furniture in a corner is pinned**, since carving takes the rows beside it.
1122
+ Pass the rects that stay put; core repaints each, and the stale image the
1123
+ copy dragged, every pan frame — so the furniture may change (a minimap's
1124
+ viewport box) and claim inside its rect without declining. A claim
1125
+ elsewhere in the region still declines, as does furniture over three
1126
+ quarters of the pane.
1127
+
1128
+ ```js
1129
+ onDragPan(dx, dy) {
1130
+ this.panX += dx;
1131
+ this.panY += dy;
1132
+ // rects inside the region, in its coordinates, that do not move
1133
+ this.scrollContents(this.contentBox(), dx, dy, null, [
1134
+ this.minimapRect(),
1135
+ this.controlsRect(),
1136
+ ]);
1137
+ }
1138
+ ```
1139
+
1140
+ Core pins the four radius-sized corners of a rounded box around your
1141
+ element itself; your element's own border ring and corners decline. A node
1142
+ mounted over the region inside a pinned rect (a zoom button, a legend) is
1143
+ furniture too: pin the rect it claims, `node.paintBounds()`, slop included,
1144
+ or its hover declines.
1145
+
1146
+ **Riders move with the pan.** Widgets mounted beside the pane in a box moved
1147
+ by exactly (dx, dy) this frame, and changed in no other way, ride the blit
1148
+ when named:
1149
+
1150
+ ```js
1151
+ onDragPan(dx, dy) {
1152
+ this.panX += dx;
1153
+ this.panY += dy;
1154
+ // the layer moves by (dx, dy) in this same frame's commit
1155
+ this.scrollContents(this.contentBox(), dx, dy, [this.bodiesLayer]);
1156
+ }
1157
+ ```
1158
+
1159
+ A rider's commit then claims nothing, and its move only what lies outside
1160
+ `rect` — nothing, if a box that paints nothing clips it to your element.
1161
+ Anything else it does is claimed normally and declines that frame's blit.
1162
+
1163
+ **Zoom is not a blit**: scaling resamples and repaints. A retained `Surface`
1164
+ shifts with [`copyWithin`](#scrolling-the-pixels-not-just-the-offset)
1165
+ instead. `REACT_X11_NO_SCROLL_BLIT=1` forces the fallback
1166
+ ([debugging.md](debugging.md#react_x11_no_scroll_blit1)).
1167
+
1168
+ ### An element that covers its box
1169
+
1170
+ An element compositing an opaque bitmap over its whole content box every
1171
+ paint (a terminal, a media frame) hides what is under it, but core still
1172
+ fills the window's, ancestors' and its own backgrounds first unless told:
1173
+
1174
+ ```js
1175
+ class TerminalNode extends Node {
1176
+ opaqueRect() {
1177
+ return this.contentBox(); // every pixel of it, alpha one, every paint
1178
+ }
1179
+
1180
+ _screenChanged() {
1181
+ // a rect inside the answer, not the node: a node claim is inflated by
1182
+ // a pixel of slop, which is outside the rect and so never covered
1183
+ this.invalidate(false, this.contentBox(), 'content');
1184
+ }
1185
+ }
1186
+ ```
1187
+
1188
+ A pass inside the answer skips those fills. Window coordinates like `abs`;
1189
+ core uses the whole pixels inside it; clipping ancestors shrink it, rounded
1190
+ ones by their radius. The promise — every pixel, alpha one, every paint,
1191
+ whatever the props — is yours: a translucent element, or one with an
1192
+ occasional background, answers `null` (the default). It is read per pass, so it may follow
1193
+ `contentBox()`.
1194
+
1195
+ **It also lets a move be copied.** When a subtree only moved this frame (a
1196
+ terminal across a desktop, a card across a graph) and something in it
1197
+ covers its box, core copies its pixels and repaints only the uncovered
1198
+ strip, the corners a radius gives up, and what is drawn over it. Covering
1199
+ means an `opaqueRect()` element or a box with a `backgroundColor` nothing
1200
+ shows through, possibly inside boxes holding nothing else. A card without
1201
+ a background is repainted where it was and where it went.
1202
+
1203
+ Other claims that frame: one from an element **under** the subtree repaints
1204
+ around the copy, not through it; one from inside, over, or unplaceable
1205
+ repaints in full. If those cover most of the copy, or need more damage
1206
+ rects than a frame allows (four on X11: a rounded card's corners plus strip
1207
+ are five), the move repaints in both places. `REACT_X11_NO_SCROLL_BLIT=1`
1208
+ turns it off ([debugging.md](debugging.md#react_x11_no_scroll_blit1)).
1209
+
1210
+ ### An element that draws past its box
1211
+
1212
+ Core claims, culls and sizes a fade's surface by a node's box grown by its
1213
+ outline and shadow. Declare ink beyond that, or it is left on screen when
1214
+ the element changes and dropped when scrolled just out of a pane:
1215
+
1216
+ ```js
1217
+ class RichTextNode extends Node {
1218
+ // a code chip is padded two pixels past the run behind it, and at the
1219
+ // start of a line that is outside the box
1220
+ paintOverhang() {
1221
+ return Math.round(2 * this.scale);
1222
+ }
1223
+ }
1224
+ ```
1225
+
1226
+ Device pixels, every side, read when core rebuilds cached paint bounds (on
1227
+ a claim): bound anything the element may draw, not what it drew last.
1228
+
1229
+ ### Drawing once instead of every frame
1230
+
1231
+ A drawing that does not change can be rendered once and composited on later
1232
+ repaints, one copy shared by every node producing the same key:
1233
+
1234
+ | method | |
1235
+ | ----------------------- | ----------------------------------------------------------------------------- |
1236
+ | `paintCachePlan()` | `null` to opt out this frame, or `{ key, x, y, width, height, format, tint }` |
1237
+ | `paintCached(ctx, box)` | draw, at the **origin of `box`** — not at `this.abs` |
1238
+
1239
+ ```js
1240
+ paintCachePlan() {
1241
+ const width = Math.round(this.abs.width);
1242
+ const height = Math.round(this.abs.height);
1243
+ if (width <= 0 || height <= 0) return null;
1244
+ return {
1245
+ key: `sparkline|${width}x${height}|${this.props.seriesId}`,
1246
+ x: Math.round(this.abs.x),
1247
+ y: Math.round(this.abs.y),
1248
+ width,
1249
+ height,
1250
+ format: 'argb32',
1251
+ tint: null,
1252
+ };
1253
+ }
1254
+
1255
+ paintCached(ctx, box) {
1256
+ this.drawSeries(ctx, box.x, box.y, box.width, box.height);
1257
+ }
1258
+ ```
1259
+
1260
+ `format: 'a8'` stores coverage and paints through `tint`: one copy serves
1261
+ every colour of a monochrome drawing, and colour stays out of the key.
1262
+
1263
+ **The key is the entire correctness surface.** It must name every input
1264
+ `paintCached` reads, derived from what `applyProps` compares; a missed one
1265
+ shows stale pixels. Develop with `REACT_X11_PAINT_CACHE=verify`, which
1266
+ re-renders every hit and complains when the drawing changed under the same
1267
+ key. Return `null` while animated, hovered, focused or blinking.
1268
+
1269
+ `paintCached` draws in **surface-local** coordinates. The cache is per X
1270
+ connection, keyed only on your string: prefix keys with your element name.
1271
+ `globalAlpha`, the clip and ancestor translation apply at composite time, so
1272
+ an ancestor's opacity animation still hits.
1273
+
1274
+ ### Parts of your drawing on layers of their own
1275
+
1276
+ On macOS the render server can run an animation — a cursor's blink, an
1277
+ edge's dash, a tooltip's fade — with no JavaScript frame, if what moves is
1278
+ on a layer of its own. Layer promotion does that for a `<box>`
1279
+ ([macos.md](macos.md#layer-promotion-the-animated-few-on-their-own-layers-above-the-surface-presenter));
1280
+ an element that paints its own content answers **sprites**: parts of its
1281
+ drawing to lift, each with what its layer shows and runs.
1282
+
1283
+ ```js
1284
+ sprites() {
1285
+ const card = this.cardRect(); // device pixels, window coordinates
1286
+ return [
1287
+ {
1288
+ key: 'card',
1289
+ rect: card,
1290
+ version: this.cardVersion,
1291
+ paint: (ctx) => this.drawCard(ctx, card), // where it is, unfaded
1292
+ opacity: 1, // what it rests at
1293
+ animations: [
1294
+ {
1295
+ id: `fade-${this.fadeRun}`,
1296
+ property: 'opacity',
1297
+ values: [0, 1],
1298
+ timings: [[0.25, 0.1, 0.25, 1]],
1299
+ duration: 300, // ms
1300
+ delay: this.fadeStart - performance.now(), // ms; negative is begun
1301
+ },
1302
+ ],
1303
+ },
1304
+ ];
1305
+ }
1306
+
1307
+ spritesLifted(keys) {
1308
+ this.lifted = keys; // paint() leaves these out now
1309
+ }
1310
+
1311
+ spriteAnimationEnded(key, id, finished) {
1312
+ this.fadeDone = true;
1313
+ this.spritesChanged(); // the frame that asks again
1314
+ }
1315
+ ```
1316
+
1317
+ | method | |
1318
+ | ----------------------------------------- | ----------------------------------------------------------------- |
1319
+ | `sprites()` | the parts on offer, or `null`; asked every frame, after layout |
1320
+ | `spritesLifted(keys)` | which are on layers now, before the frame paints |
1321
+ | `spriteAnimationEnded(key, id, finished)` | the render server is done with one: it ran out, or its layer went |
1322
+ | `spritesChanged()` | ask for a frame when the parts changed and nothing you draw did |
1323
+
1324
+ A part has:
1325
+
1326
+ - `key`; `rect` (device pixels, window coordinates); `reach` if it draws
1327
+ past `rect`; a `clip`, with `clipRadius` for round corners.
1328
+ - `paint(ctx)`, drawing it **where it is, at opacity 1, untransformed** (the
1329
+ layer applies both), and a `version` whose change paints it again.
1330
+ - At rest, `opacity` and `transform`: CSS's `matrix(a, b, c, d, e, f)` as an
1331
+ array, `e`/`f` in device pixels, about `origin` (default the rect's
1332
+ centre); out of the plane, `matrix3d()`'s sixteen numbers column by
1333
+ column, translation and perspective distance in device pixels — lifted
1334
+ only if no corner is ever behind the viewer.
1335
+ - `animations` of `opacity` or `transform`: `id`, `property`, `values`,
1336
+ `keyTimes`, `timings` (a curve per segment), `duration`, `delay`, `repeat`
1337
+ (`Infinity` loops), `autoreverse`, `hold` (stay on the last value). Hand a
1338
+ CSS transform animation over **sampled** into matrices from your own
1339
+ interpolation; Core Animation's would not turn a full turn or mix
1340
+ functions as CSS does.
1341
+
1342
+ Every frame the presenter applies promotion's rules **everywhere the part
1343
+ can be** through its animations:
1344
+
1345
+ - **Nothing painted after the element reaches into what shows of it** — no
1346
+ later sibling, no ancestor's border, ring or scrollbar. The layer is
1347
+ masked to its `clip` and square-cornered clipping ancestors (a carousel
1348
+ slide under `overflow: hidden`); a rounded ancestor must hold all of it.
1349
+ A part wholly clipped out stays lifted showing nothing, its animation
1350
+ running, up to 32 MB of such raster per window; past that, yours to draw.
1351
+ - **Neither the element nor any ancestor fades.**
1352
+ - **Your inside is yours.** Offer only parts nothing you draw later
1353
+ overlaps, except later parts; list them in paint order. A part turned
1354
+ down is yours to draw, and turns down every earlier part it overlaps.
1355
+
1356
+ A part may name an earlier one as `parent` (a spinner in a fading card):
1357
+ its layer goes in the parent's, fading, turning and clipping with it, and
1358
+ must stay inside the parent's raster. Its `rect`, `origin` and `clip` are
1359
+ with the parent untransformed. The parent's `paint` gets the keys lifted
1360
+ inside it and leaves them out, and is repainted in the frame they change.
1361
+ A child whose parent is not lifted, or that reaches out of it, is yours to
1362
+ draw, in the parent's raster where that is lifted.
1363
+
1364
+ A lifted part is a hole in your drawing from the frame `spritesLifted` says
1365
+ so; one dropped from the set is yours again that frame. Declining is always
1366
+ safe: X11, Wayland, the layer presenter and a scene that refuses never ask,
1367
+ `paint` draws everything, and animations run on your clock.
1368
+
1369
+ #### A part that shows a source
1370
+
1371
+ `contents`, in place of `paint` and `version`, is today a `VideoFrames` sink
1372
+ ([elements.md](elements.md#video)) — a camera feed in a chart, a preview in
1373
+ a document — whose every `push` reaches the layer with no window frame:
1374
+
1375
+ ```js
1376
+ sprites() {
1377
+ const frames = this.props.frames;
1378
+ if (!frames?.frame) return null; // nothing to show yet
1379
+ const rect = this.previewRect(); // device pixels, on whole pixels
1380
+ return [{ key: 'preview', rect, contents: frames }];
1381
+ }
1382
+
1383
+ paint(ctx) {
1384
+ super.paint(ctx);
1385
+ if (!this.lifted.has('preview')) this.drawPreview(ctx); // the newest frame
1386
+ }
1387
+ ```
1388
+
1389
+ The layer is exactly `rect`, the picture scaled in, so keep it on whole
1390
+ pixels or a seam shows; `spritesChanged()` when it comes or goes. A
1391
+ presenter without video surfaces — every backend but macOS — declines, and
1392
+ you draw the newest frame. `<video>` is built this
1393
+ way (`src/nodes/video.js`).
1394
+
1395
+ For every part: an animation is attached **once per `id`**; change the id
1396
+ to restart it, and a finished one is not restarted while listed. `delay`
1397
+ counts from the asking frame and is read only on the first. A part repaints
1398
+ only on a new `version`, a fractional move or a resize; a whole-pixel move
1399
+ just moves the layer.
1400
+
1401
+ Sprites need `@windowkit/appkit` 0.19 or later; on an older bridge nothing
1402
+ is asked. A bridge whose `transformForms()` lacks `matrix3d` leaves parts
1403
+ out of the plane to their elements.
1404
+
1405
+ #### A part that is its shadows
1406
+
1407
+ `shadows` in place of `paint` makes a part CSS outer `box-shadow`s, blurred
1408
+ on the GPU. `rect` is the casting box, with `rectRadius`; nothing is drawn
1409
+ inside it. Each shadow: a `rect` and its corners' `radius`, offsets `x` and
1410
+ `y`, CSS's `blur` and `color` as straight-sRGB `[r, g, b, a]` in 0–1. The
1411
+ first is on top; fold a spread into the shape yourself.
1412
+
1413
+ ```js
1414
+ sprites() {
1415
+ const card = this.cardRect(); // device pixels, window coordinates
1416
+ const s = this.scale;
1417
+ return [
1418
+ {
1419
+ key: 'card-glow',
1420
+ rect: card,
1421
+ rectRadius: 8 * s,
1422
+ shadows: [
1423
+ { rect: card, radius: 8 * s, blur: 200 * s, color: [0.09, 0.63, 0.79, 0.25] },
1424
+ { rect: card, radius: 8 * s, y: 3 * s, blur: 2 * s, color: [0.38, 0.45, 0.47, 0.05] },
1425
+ ],
1426
+ animations: [
1427
+ {
1428
+ id: `glow-${this.hoverRun}`,
1429
+ property: 'shadowBlur',
1430
+ shadow: 0, // which of them
1431
+ values: [0, 200 * s],
1432
+ duration: 250,
1433
+ },
1434
+ ],
1435
+ },
1436
+ ];
1437
+ }
1438
+ ```
1439
+
1440
+ Animations: `'shadowBlur'`, `'shadowColor'`, `'shadowOffset'` (`shadow`
1441
+ the index; blurs and `[x, y]` in device pixels), plus `opacity` and
1442
+ `transform` on the whole part. Pad between different shadow lists as CSS
1443
+ does, and sample colours where Core Animation's straight interpolation is
1444
+ not CSS's. Nothing painted after your element may reach into the glow,
1445
+ through any keyframe. While lifted, draw the box minus its shadows. Such a
1446
+ part is never inside another.
1447
+
1448
+ ### Elements that own a real X window
1449
+
1450
+ `drawn: false` is for a node with its own child X window — a GL surface, an
1451
+ XEMBED socket, a video overlay. Read the two examples in order.
1452
+
1453
+ `GlAreaNode` (`src/glnodes.js`) has no paint code; the owning `WindowNode`
1454
+ realizes it in the commit phase, so the child names its real parent from
1455
+ the start. **No X calls in the render phase**, which can be discarded.
1456
+
1457
+ X delivers a device event to the first window up the hierarchy that
1458
+ selected it: a child that selects nothing passes the pointer to the tree's
1459
+ window (as `<glarea>` does), one that selects `ButtonPress` keeps every
1460
+ press. Select only what you need; ntk selects whatever a window is listened
1461
+ to for. The owning window's hit test knows `<glarea>` surfaces, not your
1462
+ window, so a point over yours lands on what the tree has behind it.
1463
+
1464
+ `ForeignNode` (`src/foreignnodes.js`), for a resource you did not create:
1465
+
1466
+ - **Nothing in the render phase.** A `CreateWindow` from a discarded render
1467
+ leaks a server resource; a `ReparentWindow` has moved another program's
1468
+ window for real.
1469
+ - **Teardown is synchronous.** `WindowNode.destroySubtree` destroys its X
1470
+ window in the same turn as your node, and `DestroyWindow` takes every
1471
+ inferior, so a release awaiting a round trip is too late. Requests are
1472
+ ordered on the connection, which makes "hand it back, _then_ destroy the
1473
+ container" a guarantee.
1474
+ - **Give it back, do not destroy it.**
1475
+
1476
+ ## A layout algorithm of your own
1477
+
1478
+ `layout` on a box ([styling.md](styling.md#custom-layouts)) names an
1479
+ algorithm, and `registerLayout` teaches react-x11 one. A radial menu's:
1480
+
1481
+ ```js
1482
+ import { registerLayout } from 'react-x11/host';
1483
+
1484
+ registerLayout('radial', {
1485
+ options: { radius: { type: 'length', default: 80 } },
1486
+ layout(children, c, { radius }) {
1487
+ const sizes = children.map((child) => child.measure());
1488
+ const reach = Math.max(0, ...sizes.map((s) => Math.max(s.width, s.height)));
1489
+ const width = c.widthMode === 'exactly' ? c.width : 2 * radius + reach;
1490
+ const height = c.heightMode === 'exactly' ? c.height : 2 * radius + reach;
1491
+ const step = (2 * Math.PI) / Math.max(1, children.length);
1492
+ return {
1493
+ width,
1494
+ height,
1495
+ children: sizes.map((s, i) => ({
1496
+ x: width / 2 + radius * Math.cos(i * step) - s.width / 2,
1497
+ y: height / 2 + radius * Math.sin(i * step) - s.height / 2,
1498
+ })),
1499
+ };
1500
+ },
1501
+ });
1502
+ ```
1503
+
1504
+ ```jsx
1505
+ <box style={{ layout: { name: 'radial', radius: 64 } }}>
1506
+ {tools.map((tool) => (
1507
+ <ToolButton key={tool.id} tool={tool} />
1508
+ ))}
1509
+ </box>
1510
+ ```
1511
+
1512
+ **One function, two questions.** `layout(children, constraints, options,
1513
+ info)` is asked how big the content is for `constraints` (as
1514
+ `measureContent` gets them, `Infinity` for no bound), several times a pass,
1515
+ at sizes nothing is drawn at. When both modes are `'exactly'` the answer
1516
+ places too: `children`, one rect per child — `{ x, y }` from the content
1517
+ box's corner, plus a `width` and `height` to lay it out at, either left out
1518
+ being the child's own. `measureContent`'s rules apply
1519
+ ([above](#what-it-must-not-do)): no `abs`, no state, finite numbers.
1520
+
1521
+ **What it gets of a child** is a `LayoutChild`, never a node:
1522
+
1523
+ | member | |
1524
+ | ----------------------- | -------------------------------------------------------------------------------------------------- |
1525
+ | `measure(constraints?)` | its size. A bare number is `'exactly'`, an omitted axis `'unconstrained'`, `'at-most'` fit-content |
1526
+ | `intrinsicSizes()` | `{ minContentWidth, maxContentWidth }`: its content floor, and its width with no bound |
1527
+ | `options` | its `layoutItem`, against your `childOptions` |
1528
+ | `style` | its resolved style, read-only, device pixels (`gridColumn`, `alignSelf`…) |
1529
+ | `index` | its place in the list |
1530
+
1531
+ Every size is the child's **margin box**, in device pixels, so lining
1532
+ children up is right about margins without knowing they exist.
1533
+
1534
+ **Options are declared, and typed.** `options` and `childOptions` map names
1535
+ to `{ type, default }`: `'length'`, `'number'`, `'integer'`, `'boolean'`,
1536
+ `'string'`, `'any'`, or an array of allowed values. A `length` is written in
1537
+ logical pixels and arrives in device pixels. A wrong-typed value or an
1538
+ undeclared option is reported by name and takes the default. The box's own
1539
+ style (`gap`, `justifyContent`, `alignItems`) is `info.style`, so do not
1540
+ declare look-alikes. `info.scale` is for a length the schema cannot see (in
1541
+ a string, like a grid's tracks). `info.report(message, consequence)`
1542
+ reports once something wrong that can be laid out around (an unnamed grid
1543
+ area); throw only for what cannot be laid out at all.
1544
+
1545
+ **What core does.** It lays each child out at its rect, mirrors the slots
1546
+ for right-to-left and rounds them to whole pixels, places absolutely
1547
+ positioned children by their insets, measures content floors, carries a
1548
+ child's change into the next pass, and — when the algorithm throws or
1549
+ answers something that is not a size — reports it and lays the box out as
1550
+ flexbox in the same frame.
1551
+
1552
+ Register before you render. Registering a name twice with a different
1553
+ function throws unless `override: true`; under hot reload a module
1554
+ re-registering its own replaces it, and a mounted box keeps its definition
1555
+ until its style names a layout again. Worked examples: `masonry` and
1556
+ `equal-row` (`src/layouts.js`), and `grid` (`src/grid.js`), which takes no
1557
+ options and reads tracks from `info.style` and places from each child's
1558
+ `style`.
1559
+
1560
+ In TypeScript, augment `CustomLayouts` and
1561
+ `layout: { name: 'radial', radius: 40 }` type-checks, typos included;
1562
+ `CustomLayoutItem` is what a child may say:
1563
+
1564
+ ```ts
1565
+ declare module 'react-x11/host' {
1566
+ interface CustomLayouts {
1567
+ radial: { radius?: number };
1568
+ }
1569
+ }
1570
+ ```
1571
+
1572
+ ## A position of your own
1573
+
1574
+ `registerPosition` adds a positioning scheme
1575
+ ([styling.md](styling.md#custom-positions)): the box is laid out in flow, as
1576
+ `relative` is, and after every layout pass `place(node, context)` says how
1577
+ far to move it. A drop's flight home, from where the pointer let go to where
1578
+ layout put the item:
1579
+
1580
+ ```js
1581
+ import { registerPosition } from 'react-x11/host';
1582
+
1583
+ registerPosition('landing', {
1584
+ options: {
1585
+ from: { type: 'any' }, // { x, y }, window coordinates
1586
+ started: { type: 'number' },
1587
+ ms: { type: 'number', default: 200 },
1588
+ },
1589
+ place(node, { laidOut, now, options: { from, started, ms } }) {
1590
+ if (!from) return null;
1591
+ const t = Math.min(1, (now - started) / ms);
1592
+ const left = (1 - t) * (1 - t); // easing out
1593
+ return {
1594
+ x: (from.x - laidOut.x) * left,
1595
+ y: (from.y - laidOut.y) * left,
1596
+ again: t < 1,
1597
+ };
1598
+ },
1599
+ });
1600
+ ```
1601
+
1602
+ `place` returns `{ x, y }`, how far to move the node in device pixels
1603
+ (rounded for you), or null to leave it; `again: true` asks for another
1604
+ frame, which runs placement with nothing to lay out. It resizes nothing and
1605
+ sets no state: it runs on every layout pass, a scroll's included, so what
1606
+ it moves lands in the frame that scrolled.
1607
+
1608
+ Its context, in window coordinates and device pixels:
1609
+
1610
+ | field | |
1611
+ | ------------------- | --------------------------------------------------------------------------------------------- |
1612
+ | `laidOut` | `{ x, y, width, height }`: where layout put the node, before placement |
1613
+ | `pane` | nearest scroll pane: `scrollport` (inside border, over padding), `scrollX`/`scrollY`; or null |
1614
+ | `container` | the parent's content box, or for a pane's direct child its scrolled content |
1615
+ | `margin` | its margins |
1616
+ | `direction`/`scale` | the node's |
1617
+ | `now` | the frame clock, in ms |
1618
+ | `options` | its options, resolved as a layout's are |
1619
+
1620
+ `node` is the node, for its `style`: the insets are the scheme's to read
1621
+ (sticky reads them as thresholds); yoga never sees them as offsets.
1622
+
1623
+ **What core does.** It re-derives the laid-out position every pass, so
1624
+ nothing remembers an offset; claims where the node was and went as damage,
1625
+ keeping a pane's scroll blit; lifts it over siblings of the same `zIndex`
1626
+ for paint and hit testing; and when `place` throws, reports it once and
1627
+ leaves the node where layout put it until its style names another position.
1628
+ `position: 'sticky'` is written against exactly this (`placeSticky` in
1629
+ `src/layouts.js`).
1630
+
1631
+ In TypeScript, augment `CustomPositions` the way `CustomLayouts` is.
1632
+
1633
+ ## The subpath exports
1634
+
1635
+ | subpath | |
1636
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------- |
1637
+ | `react-x11/host` | `registerElement`/`Layout`/`Position`, their `unregister…` and `registered…s`, `hostTypes`, `knownElements`, `drawnKinds` |
1638
+ | `react-x11/node` | `Node`, the built-in node classes, `Scrollable`, `intrinsicSize` |
1639
+ | `react-x11/style` | `createStyles`, `flattenStyle`, `isStyleProp`, `resolveTokens`, the rest of the vocabulary |
1640
+ | `react-x11/yoga` | the layout engine: `Yoga`, `loadLayout`, `layoutLoaded`. Rarely needed (below) |
1641
+ | `react-x11/ntk` | ntk (`Path2D`, `Image`, `Pixmap`, font sources, `createClient`), the backend's `Surface`, `decodeImageBytes` |
1642
+ | `react-x11/keysyms` | the `XK_*` constants, `keysymOf`, `charOf`, `MOD`, `ctrlChordLetter` |
1643
+
1644
+ **Reach ntk through `react-x11/ntk`, never a second dependency.** Two copies
1645
+ mean two font caches and glyph atlases, and a node built against one cannot
1646
+ be painted by the other — it looks like a drawing bug. Its `Surface` is the
1647
+ backend's own (above), where ntk's is the X11 pixmap; its
1648
+ `decodeImageBytes` is what `<image>` uses — WebP everywhere, under Bun every
1649
+ format Bun reads, off the JavaScript thread — where ntk's `decodeImage` is
1650
+ PNG and JPEG, synchronously ([formats](elements.md#formats-and-what-decodes-them)).
1651
+
1652
+ **An element does not need the layout engine**: `measureContent` gets its
1653
+ constraints in words, not yoga's integers, and arranging a box's children
1654
+ is `registerLayout` ([above](#a-layout-algorithm-of-your-own)).
1655
+ `react-x11/yoga` is for an element laying out content it draws that wants
1656
+ flexbox for part of it (`@react-x11/components`' `<Html>` does, for
1657
+ `display: flex`). Use **this** engine, never your own `yoga-layout`: two
1658
+ WebAssembly instances cannot share nodes, and mixing them crashes inside
1659
+ the engine, naming nothing you wrote.
1660
+
1661
+ ```ts
1662
+ import { Yoga, loadLayout } from 'react-x11/yoga';
1663
+
1664
+ await loadLayout(); // createRoot() has already done this inside an app
1665
+ const root = Yoga.Node.create();
1666
+ root.setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
1667
+ ```
1668
+
1669
+ The enum constants are readable at once; `Node` and `Config` throw until
1670
+ the assembly is loaded, which `createRoot()` awaits
1671
+ ([packaging.md](packaging.md)).
1672
+
1673
+ ## Typing
1674
+
1675
+ Declare the element to JSX by module augmentation, as
1676
+ [typescript.md](typescript.md) describes:
1677
+
1678
+ ```ts
1679
+ import type { Style } from 'react-x11/style';
1680
+
1681
+ declare module 'react-x11/jsx-runtime' {
1682
+ namespace JSX {
1683
+ interface IntrinsicElements {
1684
+ sparkline: {
1685
+ data: number[];
1686
+ color?: string;
1687
+ style?: Style;
1688
+ ref?: React.Ref<unknown>;
1689
+ };
1690
+ }
1691
+ }
1692
+ }
1693
+ ```
1694
+
1695
+ `Context2D` from `react-x11/node` is the canvas-shaped subset every backend
1696
+ implements, so `paintContent(ctx: Context2D)` type-checks every call; a
1697
+ member not every backend has is optional (`globalCompositeOperation`, which
1698
+ a native context takes only for modes its bridge draws — assign and read
1699
+ back) or absent. `paintCachePlan`, `paintCached`, `opaqueRect` and the rest
1700
+ of the contract are on the class, with `PaintCachePlan` for the plan.