react-x11 2.51.0 → 2.52.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 (91) hide show
  1. package/README.md +87 -54
  2. package/docs/README.md +410 -0
  3. package/docs/accessibility.md +628 -0
  4. package/docs/appearance.md +416 -0
  5. package/docs/architecture/accessibility.md +470 -0
  6. package/docs/architecture/animation.md +707 -0
  7. package/docs/architecture/container-queries.md +525 -0
  8. package/docs/architecture/custom-layout.md +334 -0
  9. package/docs/architecture/drag-and-drop.md +941 -0
  10. package/docs/architecture/element-layer-contents.md +622 -0
  11. package/docs/architecture/frame-pacing.md +255 -0
  12. package/docs/architecture/grid-layout.md +358 -0
  13. package/docs/architecture/protocol-efficiency.md +357 -0
  14. package/docs/architecture/video.md +1149 -0
  15. package/docs/architecture/windowed-regions.md +854 -0
  16. package/docs/click-to-component.md +125 -0
  17. package/docs/clipboard.md +281 -0
  18. package/docs/components.md +1749 -0
  19. package/docs/dbus.md +277 -0
  20. package/docs/debugging.md +464 -0
  21. package/docs/desktop-calendar.md +284 -0
  22. package/docs/desktop.md +648 -0
  23. package/docs/devtools.md +250 -0
  24. package/docs/drag-and-drop.md +663 -0
  25. package/docs/ecosystem/animation.md +394 -0
  26. package/docs/ecosystem/data-fetching.md +201 -0
  27. package/docs/ecosystem/dev-tooling.md +436 -0
  28. package/docs/ecosystem/forms.md +313 -0
  29. package/docs/ecosystem/headless.md +219 -0
  30. package/docs/ecosystem/i18n.md +89 -0
  31. package/docs/ecosystem/icons.md +410 -0
  32. package/docs/ecosystem/layout.md +216 -0
  33. package/docs/ecosystem/routing.md +146 -0
  34. package/docs/ecosystem/state.md +361 -0
  35. package/docs/ecosystem/testing.md +500 -0
  36. package/docs/ecosystem/theming.md +540 -0
  37. package/docs/ecosystem.md +473 -0
  38. package/docs/elements.md +2762 -0
  39. package/docs/embedding.md +288 -0
  40. package/docs/events.md +1025 -0
  41. package/docs/extending.md +2242 -0
  42. package/docs/eyedropper.md +223 -0
  43. package/docs/filedialog.md +258 -0
  44. package/docs/frame.md +580 -0
  45. package/docs/gl.md +170 -0
  46. package/docs/globalmenu.md +278 -0
  47. package/docs/glx.md +137 -0
  48. package/docs/macos.md +3295 -0
  49. package/docs/notifications.md +164 -0
  50. package/docs/packaging.md +611 -0
  51. package/docs/permissions.md +148 -0
  52. package/docs/react-features.md +430 -0
  53. package/docs/remote.md +223 -0
  54. package/docs/scale.md +339 -0
  55. package/docs/security.md +132 -0
  56. package/docs/styling.md +1928 -0
  57. package/docs/system.md +599 -0
  58. package/docs/testing.md +514 -0
  59. package/docs/typescript.md +117 -0
  60. package/docs/uri-schemes.md +380 -0
  61. package/docs/wayland-backend.md +591 -0
  62. package/docs/wayland.md +545 -0
  63. package/docs/windows-embedding.md +271 -0
  64. package/docs/windows-gl.md +221 -0
  65. package/docs/windows-integrations.md +233 -0
  66. package/docs/windows.md +1404 -0
  67. package/package.json +10 -3
  68. package/src/bus.js +39 -26
  69. package/src/cli/create.d.ts +43 -0
  70. package/src/cli/create.js +305 -0
  71. package/src/cli/env.js +484 -0
  72. package/src/cli/index.js +29 -2
  73. package/src/cli/templates/AGENTS.template.md +195 -0
  74. package/src/cli/templates/DESIGN.template.md +269 -0
  75. package/src/desktopintegration.js +2 -1
  76. package/src/desktopsettings.js +193 -24
  77. package/src/filedialog.js +3 -3
  78. package/src/keyboardstate.js +28 -0
  79. package/src/node.d.ts +10 -10
  80. package/src/portal.js +31 -0
  81. package/src/screencolor.js +5 -10
  82. package/src/types/system.d.ts +8 -6
  83. package/src/wayland/app.js +59 -0
  84. package/src/wayland/clipboard.js +14 -7
  85. package/src/wayland/connection.js +3 -0
  86. package/src/wayland/context2d.js +129 -10
  87. package/src/wayland/protocols/index.json +6 -0
  88. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  89. package/src/wayland/seat.js +28 -1
  90. package/src/wayland/swapchain.js +23 -0
  91. package/src/wayland/xkb.js +137 -0
@@ -0,0 +1,2242 @@
1
+ # Extending the element vocabulary
2
+
3
+ `<box>`, `<text>`, `<glarea>` and the rest are built in, but the list is not
4
+ closed. `registerElement()` adds an element from outside the package — a
5
+ `<sparkline>`, a tray icon, an XEMBED `<foreign>` — so a sibling package can
6
+ ship one without forking react-x11 and without the core growing.
7
+
8
+ ```js
9
+ import { registerElement } from 'react-x11/host';
10
+ import { Node } from 'react-x11/node';
11
+
12
+ class SparklineNode extends Node {
13
+ constructor(props, app) {
14
+ super('sparkline', props, app); // the kind must be the element name
15
+ }
16
+
17
+ paint(ctx) {
18
+ super.paint(ctx); // background, border, clip
19
+ const { x, y, width, height } = this.abs;
20
+ const data = this.props.data ?? [];
21
+ const max = Math.max(1, ...data);
22
+ ctx.beginPath();
23
+ data.forEach((value, i) => {
24
+ const px = x + (width * i) / Math.max(1, data.length - 1);
25
+ const py = y + height - (height * value) / max;
26
+ i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py);
27
+ });
28
+ ctx.strokeStyle = this.props.color ?? '#000';
29
+ ctx.stroke();
30
+ }
31
+ }
32
+
33
+ registerElement('sparkline', {
34
+ create: (props, app) => new SparklineNode(props, app),
35
+ semanticNames: ['data', 'color'],
36
+ });
37
+ ```
38
+
39
+ ```jsx
40
+ <sparkline
41
+ data={[1, 4, 2, 8]}
42
+ color="#c0392b"
43
+ style={{ width: 120, height: 40 }}
44
+ />
45
+ ```
46
+
47
+ Register before you render. The registry is consulted when an element is
48
+ first created, so a late registration only affects trees mounted after it.
49
+
50
+ ## The definition
51
+
52
+ | field | |
53
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- |
54
+ | `create(props, app, hostCtx)` | builds the node. `app` is the ntk connection — the second argument every built-in node constructor takes. Must return a `Node`. |
55
+ | `drawn` | default `true`: lays out with yoga and paints into the owning window. `false` for a node that owns a real child X window (see below). |
56
+ | `semanticNames` | prop names this element owns even though they are also style names |
57
+ | `selfDamagedProps` | prop names whose damage the element's own `applyProps` claims (see [Drawing a scene](#drawing-a-scene-into-one-node)) |
58
+ | `childrenAllowed` | default `true`; `false` rejects children by name instead of laying out something that never paints |
59
+ | `override` | replace an existing registration. Off by default — two packages claiming one name is a conflict worth hearing about |
60
+
61
+ Two of these exist because getting them wrong fails a long way from the
62
+ cause, so they are worth understanding rather than copying:
63
+
64
+ **`drawn` decides whether your element paints at all.** `paintOrder()`
65
+ filters children on the set of drawn kinds; a node missing from it lays out
66
+ correctly, reports a sensible `abs` rect, and never appears on screen, with
67
+ no error anywhere. `registerElement` puts you in that set unless you opt out.
68
+
69
+ **`semanticNames` is the difference between DEV and production.** react-x11
70
+ throws in development on `<box width={10} />`, because `width` is a style
71
+ property written flat — a real mistake with an unhelpful failure otherwise.
72
+ An element whose own vocabulary overlaps the style vocabulary (`width`,
73
+ `color`, `fontSize`) therefore has to say so, or it throws on its own props
74
+ in development and works in production. `<window width>` is the built-in
75
+ precedent: on a `<window>` it is the X window's width and never style.
76
+
77
+ `react-x11/style`'s `isStyleProp(name)` is how to check whether a name you
78
+ are about to use needs declaring.
79
+
80
+ ## Registering under hot reload {#hot-reload}
81
+
82
+ Register at module scope — the pattern everything above shows, and the one
83
+ tree-shaking forces on a component library. That module scope re-runs when
84
+ [`react-x11/refresh`](ecosystem/dev-tooling.md#react-refresh) hot-reloads
85
+ the module, so `registerElement` has a re-registration policy rather than
86
+ a footnote:
87
+
88
+ - **The same `create` reference is never a conflict.** A definition
89
+ arriving twice replaces silently, latest flags winning.
90
+ - **Under an active hot-reload session, any duplicate replaces silently.**
91
+ The session begins with the first hot re-import and covers the rest of
92
+ the process: from then on, "already registered" would only mean
93
+ "registered by the previous version of yourself". Mounted nodes keep the
94
+ old prototype until they remount — the same staleness contract Fast
95
+ Refresh has for classes; new mounts get the new definition.
96
+ - **Outside a session, a duplicate still throws.** Two packages claiming
97
+ one element name in a plain run is a conflict to be told about, and
98
+ `override: true` remains the way to say a replacement is deliberate.
99
+
100
+ One thing the hot loader adds: inside a hot module, call it through a
101
+ namespace or default import (`Host.registerElement(...)`) — a _named_
102
+ import called at module top level is a transform error there, because
103
+ named-import bindings initialize in a microtask after a reload.
104
+
105
+ ## The node contract
106
+
107
+ `Node` already implements the whole reconciler-facing surface, so an element
108
+ that only draws needs a constructor and a `paint`. What you may override:
109
+
110
+ | method | when |
111
+ | ------------------------------ | ------------------------------------------------------------------------------------------------------------ |
112
+ | `paint(ctx)` | draw. Call `super.paint(ctx)` first for background, border and clip, then draw inside `this.abs`. |
113
+ | `paintContent(ctx)` | draw _between_ the background and the children — where the built-ins draw, and what a scroller needs (below) |
114
+ | `opaqueRect()` | the rect you cover with opaque pixels on every paint, so a pass inside it skips the fills under you (below) |
115
+ | `viewportFixedRects()` | what you draw fixed to your scroll pane's viewport, so its scroll's blit repaints it rather than copying it |
116
+ | `paintOverhang()` | how far you draw past your box, so claims and culls cover it (below) |
117
+ | `applyProps(next, prev)` | props changed. Call `super.applyProps(next, prev)`; invalidate if you cache anything derived. |
118
+ | `paintChanged(next, prev)` | did anything you draw change? Only for an element that claims its own damage (below) |
119
+ | `measureContent(constraints)` | your content has a size of its own (below). Leaves only — an element that measures has no children. |
120
+ | `default*(ev)` | the element's own behaviour for a key, a press or focus (below) — what makes it _interactive_. |
121
+ | `hitTest(x, y)` | non-rectangular hit areas. The default walks children in reverse paint order. |
122
+ | `insertBefore` / `removeChild` | only if children mean something structural to you |
123
+ | `destroySubtree()` | release anything you allocated (pixmaps, fonts, timers). Call `super.destroySubtree()`. |
124
+
125
+ And what you read:
126
+
127
+ - `this.props` — the current props, replaced wholesale on update.
128
+ - `this.style` — the flattened `style` prop with the active `:hover` /
129
+ `:focus` / `:active` / `:disabled` blocks overlaid, and `$token`
130
+ references already resolved against the theme. **Everything that paints or
131
+ lays out reads this, never `props`.**
132
+ - `this.abs` — position and size within the owning window, valid after
133
+ layout.
134
+ - `this.contentBox()` — `abs` inset by the border and the padding: where the
135
+ content goes, and where every built-in draws.
136
+ - `this.scale` — device pixels per logical pixel ([scale.md](scale.md)).
137
+ `this.style`, `this.abs`, `contentBox()`, the paint context, every rect you
138
+ hand `invalidate` or `scrollContents` and every rect you report to a screen
139
+ reader are device pixels already; a synthetic event's `x`/`y` are logical,
140
+ so a hit test against `abs` reads `ev.nativeEvent.x`/`y`, or multiplies.
141
+ On a 1x display the two units coincide — which is exactly how an element
142
+ that mixes them passes every headless test and then hovers, drags and
143
+ frames at half the distance on a retina panel.
144
+ `renderX11(element, { scale: 2 })` is the test that catches it. It is a
145
+ per-node number rather than a per-window one — an enclosing `scale` prop
146
+ zooms a subtree ([scale.md](scale.md#a-subtree-of-its-own)) — so read it
147
+ off the node rather than caching one for the tree.
148
+ - `this.resolvedTextStyle()` — the text style this node resolves to, ready to
149
+ hand to `app.fonts.layout` (below).
150
+ - `this.resolveColor(value)` — a colour from this node's style as one a
151
+ context can paint: the value itself, or for
152
+ [`currentColor`](styling.md#currentcolor) the node's ink. Every colour
153
+ core paints goes through it, so a colour of yours read off `this.style`
154
+ should too — `ctx.strokeStyle = this.resolveColor(this.style.borderColor)`
155
+ — or it paints nothing where the app wrote the keyword. A node whose
156
+ style paints in `currentColor` repaints when its ink moves, without being
157
+ asked.
158
+ - `this.direction` — which way this node reads, resolved: `'ltr'` or
159
+ `'rtl'`, never `'inherit'`. Yoga has already mirrored the boxes, so this
160
+ is for what you draw _inside_ one — which side a mark sits on, which way
161
+ a chevron points, what a pointer coordinate means
162
+ ([styling.md](styling.md#direction-and-the-logical-edges)).
163
+ - `this.root` — the owning `<window>` node; `this.app` — the ntk connection.
164
+ - `this.theme` — the nearest theme at or above this node.
165
+ - `this.paintDamage()` — the rect the pass being painted covers, or `null`
166
+ for a full one. Only an element that draws a whole scene needs it
167
+ ([below](#drawing-a-scene-into-one-node)).
168
+
169
+ To ask for a repaint: `this.invalidate(layout, damage, reason)`. Pass the
170
+ damage — `this` is usually it, or a rect when you know a tighter one. An
171
+ invalidation with no bound repaints the whole window, which is this
172
+ renderer's main performance bug class (see [debugging.md](debugging.md)).
173
+ `reason` joins the closed set the diagnostics print. Damage is collected on
174
+ the window node and `invalidate` on any node forwards there, so an element
175
+ that is not attached yet invalidates nothing rather than throwing.
176
+
177
+ And to take the keyboard focus: `this.focus()` moves it here as a click
178
+ would and hands the node back, `this.blur()` gives it up, `this.focused` and
179
+ `this.focusWithin` answer where it is. They claim their own damage — a
180
+ focused control repaints itself, and its ring, without being asked.
181
+
182
+ ### Text of your own
183
+
184
+ An element that draws text — a badge, a code editor, a terminal — shapes it
185
+ through ntk's font manager, and the two things it needs to do that are the
186
+ two accessors above:
187
+
188
+ ```js
189
+ class BadgeNode extends Node {
190
+ paint(ctx) {
191
+ super.paint(ctx); // background, border, clip
192
+ const fonts = this.app?.fonts;
193
+ if (!fonts) return; // headless: no font manager, nothing to shape with
194
+ const box = this.contentBox();
195
+ const layout = fonts.layout(
196
+ String(this.props.label ?? ''),
197
+ this.resolvedTextStyle(),
198
+ { maxWidth: box.width || undefined },
199
+ );
200
+ layout.draw(ctx, box.x, box.y);
201
+ }
202
+ }
203
+ ```
204
+
205
+ **`contentBox()` is not `abs` minus `style.padding`.** The insets come off
206
+ the layout, which is where `padding: '10%'`, the per-side overrides and the
207
+ border widths have already been resolved against this frame's size — so the
208
+ box an element computes from the style bag agrees with the one `<text>` uses
209
+ only by luck, and stops agreeing the next time the vocabulary grows another
210
+ edge — the per-side border widths were the last one to arrive.
211
+
212
+ **`resolvedTextStyle()` is what makes the type of an app reach your
213
+ element.** It is the node's own font properties over what it **inherits**
214
+ from the elements around it, and under that what the palette says text with
215
+ none of its own is set in — in the shape `fonts.layout` wants (`family`, not
216
+ `fontFamily`; `variations`, not `fontVariationSettings`; `features`, what
217
+ `fontVariantNumeric` and `fontFeatureSettings` resolve to together). An
218
+ element that reads `this.style.fontFamily` instead is one that a
219
+ `<ThemeProvider value={{ fontFamily: 'Inter' }}>` does not reach, and one
220
+ that a `<box style={{ color: theme.dim }}>` around it does not dim — bugs an
221
+ app author can only work around by growing props on your element and
222
+ threading them in. Ask, and the element inherits exactly what a `<text>`
223
+ sibling inherits, including a `:hover` block on the row it sits in
224
+ ([styling.md](styling.md#inheritance-the-ink-the-face-and-the-size)).
225
+
226
+ Both are read at paint time. `contentBox()` is computed each call, since the
227
+ box changes with every layout; `resolvedTextStyle()` is memoised and dropped
228
+ by everything that can move it — a style change, a state block, an
229
+ animation tick, a theme swap, an element above it changing its own type — so
230
+ reading it per frame costs a property lookup and never goes stale. If the
231
+ text is also what _sizes_ the element, shape it in `measureContent` as well;
232
+ `<text>` memoizes its layout per max-width for exactly that reason. An
233
+ element that keeps a cache of its own keyed on the type — a shaped run, a
234
+ rasterized label — should drop it in **`_textStyleMoved(cost)`**, which is
235
+ called with `2` when a glyph can have moved and `1` when only the ink or the
236
+ glyph rounding did. The default re-measures on the first and repaints on the
237
+ second, which is right for most elements.
238
+
239
+ **Text drawn where a context is not.** A GL surface's labels, or a signed
240
+ distance field made from a string, need the text as pixels rather than
241
+ drawn onto a surface — and a surface read back is a round trip for pixels
242
+ the engine had, one that answers asynchronously and in the ink the screen
243
+ gets (hinted, gamma and contrast applied). A layout answers
244
+ **`coverage({ pad })`** instead (#673): how much of each device pixel its
245
+ glyphs cover, one byte a pixel, synchronously — the layout box in whole
246
+ pixels with `pad` round it, the layout's origin at `(pad, pad)`. Linear,
247
+ grayscale, unhinted: the outlines' own coverage, which is what a raster that
248
+ will be scaled wants.
249
+
250
+ ```js
251
+ const coverage = layout.coverage?.({ pad: 4 }) ?? null;
252
+ if (!coverage) {
253
+ // no answer on this engine: draw onto a Surface and read it back
254
+ }
255
+ ```
256
+
257
+ It is optional on purpose: ntk's layout answers it (X11, Wayland, the mock),
258
+ and so do DirectWrite's (Windows) and CoreText's (macOS), each from the
259
+ release that added it. Feature-detect, keep a readback, and never assume
260
+ the two are pixel-identical — `draw` snaps and hints, coverage does
261
+ neither.
262
+
263
+ ### Answering for your own text
264
+
265
+ _Issue #259._ An element that draws text can also let the user **select**
266
+ it. There is no registration call and no list of blessed kinds: a
267
+ `selectable` ancestor walks its subtree and asks every node four questions,
268
+ and an element that answers them is part of the document.
269
+
270
+ ```js
271
+ class LogViewNode extends Node {
272
+ // the string the three below index into — null (the default) means
273
+ // "this element has no text", which is the honest answer for a <box>
274
+ textContent() {
275
+ return this.lines.join('\n');
276
+ }
277
+
278
+ // window coordinates in, a code-point index out. Clamp: a point past the
279
+ // end of the text is the end of the text, which is what a drag into the
280
+ // margin depends on
281
+ textIndexAt(x, y) {
282
+ return this._layout().indexAt(x - this._originX(), y - this._originY());
283
+ }
284
+
285
+ // where a caret at that index stands: a zero-width rect, glyph top to
286
+ // glyph bottom
287
+ textCaretRect(index) {
288
+ const caret = this._layout().caretPosition(index);
289
+ return {
290
+ x: this._originX() + caret.x,
291
+ y: this._originY() + caret.y,
292
+ width: 0,
293
+ height: caret.height,
294
+ };
295
+ }
296
+
297
+ // and the bands a highlight over [start, end) fills
298
+ textRangeRects(start, end) {
299
+ /* one per line — see below */
300
+ }
301
+
302
+ paint(ctx) {
303
+ super.paint(ctx);
304
+ // the range the document selection has claimed of *this* element's
305
+ // text, pushed down whenever it moves. Paint it under the glyphs.
306
+ const range = this.selectionRange;
307
+ if (range) {
308
+ ctx.fillStyle = this.selectionColor;
309
+ const rects = [];
310
+ for (const r of this.textRangeRects(range.start, range.end)) {
311
+ rects.push(r.x, r.y, r.width, r.height);
312
+ }
313
+ ctx.fillRects(rects);
314
+ }
315
+ this._layout().draw(ctx, this._originX(), this._originY());
316
+ }
317
+ }
318
+ ```
319
+
320
+ **Two spaces, and both are the ones already in use.** Indices are **code
321
+ points** — the space ntk's `TextLayout.caretPosition`/`indexAt` speak, so an
322
+ emoji is one position and not two — and rectangles are in the **owning
323
+ window's** coordinates, the same as `abs` and `contentBox()`: device pixels.
324
+ (A mouse event's `x`/`y` are the same coordinates in logical pixels —
325
+ [scale.md](scale.md).) An element that answers in its own local coordinates
326
+ is one whose highlight is drawn somewhere else on the screen.
327
+
328
+ **Answer from what you draw.** `<text>` computes its layout and its origin
329
+ in one place and uses it for painting and for all four accessors, because a
330
+ caret answered from a differently-placed layout is a caret in the wrong
331
+ place and nothing about it looks like a bug in the accessor. If your element
332
+ scrolls, the origin has the scroll offset in it — that is what makes a hit
333
+ test right after the user has scrolled.
334
+
335
+ **One band per line, and one per direction run.** A selection is contiguous
336
+ in _logical_ order and a line is laid out in _visual_ order, so a range that
337
+ crosses from Latin into Arabic covers two disjoint stretches of pixels. If
338
+ you are drawing with ntk's `TextLayout`, walk `line.runs` and intersect each
339
+ one with the range rather than filling from one caret x to the other;
340
+ `<text>` does this and `test/text-selection.test.js` pins it.
341
+
342
+ **Say if you own a selection of your own.** An element that edits — anything
343
+ with a caret the user moves — sets `hasOwnSelection = true` in its
344
+ constructor. A `selectable` document then skips its subtree whole and never
345
+ takes its presses, which is what keeps a `<textinput>` inside a document
346
+ from having half of it lit by somebody else's drag.
347
+
348
+ **Presses arrive through the base class.** `Node`'s `defaultMouseDown`,
349
+ `defaultMouseDrag`, `defaultMouseUp` and `defaultKeyDown` hand the gesture
350
+ to the nearest `selectable` ancestor, so an element that only draws needs to
351
+ write none of them. An element that overrides one and does not call `super`
352
+ has taken that gesture for itself — right for an editor, and the reason a
353
+ drawing element that wants both should end its handler with
354
+ `super.defaultMouseDown(ev)`.
355
+
356
+ Everything about the surface side — what a copy assembles, what the
357
+ separators are, which keys are bound — is in
358
+ [elements.md](elements.md#selecting-text).
359
+
360
+ ### A size of your own
361
+
362
+ `<box>` is as big as its style and its children say; a gauge, a chart, a
363
+ terminal or an editor is as big as its _content_ says. An element with a size
364
+ of its own implements **`measureContent`**, and layout asks it whenever the
365
+ size on an axis is the element's to give:
366
+
367
+ ```js
368
+ const TICK = 30;
369
+
370
+ class GaugeNode extends Node {
371
+ measureContent({ width }) {
372
+ const ticks = Math.max(1, Number(this.props.ticks ?? 4));
373
+ // never wider than the offer, never narrower than one tick
374
+ return { width: Math.max(TICK, Math.min(ticks * TICK, width)), height: 24 };
375
+ }
376
+
377
+ applyProps(next, prev) {
378
+ const before = prev ?? this.props;
379
+ super.applyProps(next, prev);
380
+ // the measurement reads `ticks`, so a new one has to be taken
381
+ if (next.ticks !== before.ticks) this.invalidateMeasure();
382
+ }
383
+ }
384
+ ```
385
+
386
+ `<gauge ticks={6} />` is then 180×24 with nothing in the style saying so, the
387
+ way `<textarea rows={6}>` is six lines tall.
388
+
389
+ It has to be a **method on the class**: the base `Node` constructor is what
390
+ wires it to layout, so an element that assigns it in its own constructor is
391
+ too late and never gets asked. `invalidateMeasure()` says so in development.
392
+
393
+ #### What it is asked
394
+
395
+ The argument is `{ width, height, widthMode, heightMode }`. Each mode says
396
+ what the number beside it means, and an unbounded axis arrives as `Infinity`
397
+ rather than as a sentinel — so `Math.min(preferred, width)` is the right
398
+ answer in all three, and the mode is there for the cases where it is not:
399
+
400
+ | asked with | the element answers |
401
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------ |
402
+ | `unconstrained` | its natural size — nothing bounds this axis (`<textarea>`: its preferred width, `rows` tall) |
403
+ | `at-most` | its natural size, clamped into the offer |
404
+ | `at-most` with 0 | **the smallest it can be drawn at** — an editor: a character or two of one line; something that scrolls inside: honestly small |
405
+ | `exactly` | the style decided this axis; hand the number back and answer for the other one |
406
+
407
+ The `at-most 0` row is the one worth reading twice, because it is not only
408
+ the layout that asks it. The renderer measures the smallest size every node
409
+ can be drawn at — by laying the tree out with **no room at all** and asking
410
+ each leaf directly — and writes that back as the element's own floor, so a
411
+ row too narrow for it squeezes it that far and no further
412
+ ([elements.md](elements.md#everything-shrinks-nothing-shrinks-to-nothing)).
413
+ The same measurement read at the top of the tree is what
414
+ `<window minWidth="auto">` sends the window manager. An element that answers
415
+ 0 there is saying it can be squeezed to nothing, and both floors come out
416
+ without it.
417
+
418
+ `at-most` is what is _available_, not a cap that will be enforced: an element
419
+ may answer larger and it will be laid out at what it asked for, overflowing
420
+ whatever contains it. That is how `<text textWrap="nowrap">` works, and it is
421
+ the honest answer for content that genuinely does not reflow — a code block
422
+ that scrolls sideways rather than wrapping.
423
+
424
+ #### What it must not do
425
+
426
+ `measureContent` is a **question about a hypothetical**, asked several times
427
+ per layout and at sizes nothing will ever be drawn at. So:
428
+
429
+ - **do not read `this.abs`** — it is the result of the layout doing the
430
+ asking, and last frame's answer at that;
431
+ - **do not paint, invalidate, or set state.** Cache derived work if it is
432
+ expensive (`<text>` memoizes its shaped layout per width), but keep the
433
+ answer a pure function of the content and the constraints;
434
+ - **answer with finite numbers.** Content that has not arrived yet is
435
+ `{ width: 0, height: 0 }`, not `undefined` — anything else throws naming
436
+ the element, because a `NaN` would otherwise spread silently through every
437
+ ancestor's rect.
438
+
439
+ An element that measures itself **cannot have children**: layout sizes it
440
+ from the measure function and gives its children no say, so rendering one
441
+ inside it is an error naming both elements.
442
+
443
+ #### Saying the answer moved
444
+
445
+ Layout caches what an element measured to. `invalidateMeasure(reason?)` is
446
+ how the element says that cache is stale — a prop the measurement read
447
+ changed, data arrived, a font loaded — and the next frame asks again. Nothing
448
+ does this for you: only the element knows which of its inputs the measurement
449
+ reads. `reason` joins the closed set the diagnostics print
450
+ ([debugging.md](debugging.md#invalidation-reasons)); the default is
451
+ `'measure'`.
452
+
453
+ #### Content with an aspect ratio
454
+
455
+ The `<image>`/`<svg>` shape — a natural size to keep the proportions of — is
456
+ one call:
457
+
458
+ ```js
459
+ import { Node, intrinsicSize } from 'react-x11/node';
460
+
461
+ class ThumbNode extends Node {
462
+ measureContent(constraints) {
463
+ return intrinsicSize(this.decoded ?? { width: 0, height: 0 }, constraints);
464
+ }
465
+ }
466
+ ```
467
+
468
+ It shrinks to the width on offer with the height following, and scales the
469
+ width from a style height when only that axis is fixed — which is what an
470
+ `<img>` does, and is the whole of `<image>`'s own measurement.
471
+
472
+ ### Behaviour of your own
473
+
474
+ An element that _does_ something — an editor, a terminal, a table with cell
475
+ editing, a tree with type-ahead — implements the **default actions**. They are
476
+ the same seam `<textinput>`'s editing runs on, and the ordering is the whole
477
+ point:
478
+
479
+ 1. the application's `onKeyDown` / `onMouseDown` / … handlers, capture then
480
+ bubble;
481
+ 2. **if none of them called `ev.preventDefault()`**, the element's `default*`
482
+ method.
483
+
484
+ So an app can veto the behaviour, or run something before it, without knowing
485
+ how the element is built — which is what `<textinput onKeyDown>` has always
486
+ been able to do, and is the reason to implement behaviour here rather than in
487
+ a React component wrapping the element.
488
+
489
+ | method | when |
490
+ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
491
+ | `defaultKeyDown(ev)` | a key, delivered to the focused node. `ev.keysym` is the `XK_*` constant — the Latin one, so a chord matches under any layout; `codepoint >= 0x20` is the test for "this is text" |
492
+ | `defaultKeyUp(ev)` | that key was released. Rare — nothing in core needs it — and there for an element that answers the whole keystroke rather than the press, such as `<foreign>` forwarding into an embedded client |
493
+ | `defaultComposition(ev)` | text still being typed — a dead key, a Compose sequence. `ev.type` is the phase, `ev.data` what to show, or at the end what to insert |
494
+ | `defaultMouseDown(ev)` | a press. Place a caret, grab a handle; `ev.capturePointer()` to keep the rest of the gesture |
495
+ | `defaultMouseDrag(ev)` | motion while _this_ element holds the press — including outside its box, which `onMouseMove` does not give you |
496
+ | `defaultMouseUp(ev)` | that press was released |
497
+ | `defaultMouseMove(ev)` | the pointer moved over this element, no button down — light the node, the edge, the handle it is over. The first one after it arrives is the enter |
498
+ | `defaultMouseLeave(ev)` | …and the pointer left: put that back |
499
+ | `defaultWheel(ev)` | the wheel, before the scroll chain — for the element whose wheel is not a scroll. `ev.preventDefault()` consumes it |
500
+ | `defaultContextMenu(ev)` | right-click, after the press: open your own menu. Separate so suppressing it keeps the caret placement |
501
+ | `defaultFocus(info)` | this element became the focused node (or its window got the X focus back): start the caret blinking. `info` is `{ reason, backwards }` — `'key'` with a direction is a Tab, which is the only thing that has ever needed it |
502
+ | `defaultBlur()` | focus left, or the window lost it: stop it |
503
+
504
+ ```js
505
+ import { Node, CARET_BLINK_MS } from 'react-x11/node';
506
+ import { XK_TAB, XK_ESCAPE } from 'react-x11/keysyms';
507
+
508
+ class EditorNode extends Node {
509
+ constructor(props, app) {
510
+ super('codeeditor', props, app);
511
+ // without this nothing focuses it, and no key ever arrives
512
+ this.focusableByDefault = true;
513
+ this.defaultCursor = 'text';
514
+ }
515
+
516
+ defaultKeyDown(ev) {
517
+ if (ev.keysym === XK_ESCAPE) {
518
+ // the way out: the next Tab leaves, so the element is never a trap
519
+ this._tabEscapes = true;
520
+ return;
521
+ }
522
+ if (ev.keysym === XK_TAB) {
523
+ if (this._tabEscapes) {
524
+ this._tabEscapes = false; // one Tab, then it indents again
525
+ return; // …and this one belongs to focus traversal
526
+ }
527
+ this.indent(ev.shiftKey ? -1 : 1);
528
+ ev.preventDefault(); // this one is mine
529
+ return;
530
+ }
531
+ this._tabEscapes = false;
532
+ if (ev.codepoint >= 0x20) this.insert(ev.key);
533
+ }
534
+
535
+ defaultFocus() {
536
+ this._caretOn = true;
537
+ this._blink = setInterval(() => {
538
+ this._caretOn = !this._caretOn;
539
+ this.root?.invalidate(false, this, 'caret');
540
+ }, CARET_BLINK_MS);
541
+ this._blink.unref?.();
542
+ }
543
+
544
+ defaultBlur() {
545
+ clearInterval(this._blink);
546
+ this._blink = null;
547
+ this._caretOn = false;
548
+ }
549
+ }
550
+ ```
551
+
552
+ Details worth having in writing:
553
+
554
+ **A gesture is vetoed once, at its press.** `defaultMouseDrag` and
555
+ `defaultMouseUp` are the continuation of the press `defaultMouseDown` got, so
556
+ a handler that prevented the press means none of the three run — an element
557
+ never hears about motion it has no press behind, and does not need a
558
+ `dragging` flag of its own to discover that.
559
+
560
+ **Hover is motion, not a state you are handed.** `defaultMouseMove` runs for
561
+ the element the pointer is over — coalesced to once per frame, like every
562
+ motion — and `defaultMouseLeave` when it goes elsewhere, which is everything
563
+ a scene element needs to light the node, edge or handle under the pointer and
564
+ put it back. There is no `defaultMouseEnter`: the first motion after the
565
+ pointer arrives _is_ the enter, and it carries the position an enter would
566
+ have to be asked for. A capture suspends the pair — while a gesture owns the
567
+ pointer, hover stays frozen where it was and the motion goes to
568
+ `defaultMouseDrag` instead. And a node that unmounts while hovered is
569
+ _forgotten_ rather than left, exactly as one that unmounts while focused is,
570
+ so anything with a lifetime behind the highlight is released in
571
+ `destroySubtree` as well.
572
+
573
+ **The cursor is the point's.** `defaultCursor` is one cursor for the whole
574
+ element. An element that draws a document, a graph or an editor has one per
575
+ part — a link, a handle, text — and names it with `cursorAt(x, y)`, asked on
576
+ every motion over it with the pointer in device pixels, the unit `abs` is
577
+ in. It answers a cursor name, or null for nothing in particular, which falls
578
+ through to `defaultCursor` and then to the parent. A `cursor` style on the
579
+ element still wins, and a capture freezes the cursor with the rest of hover.
580
+
581
+ ```js
582
+ cursorAt(x, y) {
583
+ return this.linkAt(x - this.abs.x, y - this.abs.y) ? 'pointer' : 'text';
584
+ }
585
+ ```
586
+
587
+ **The wheel that is not a scroll.** `canScroll`/`scrollBy`
588
+ ([below](#scrolling-content-you-painted)) route the wheel for content that
589
+ scrolls, and they hand the element deltas only — which is the whole of the
590
+ gesture when the answer is "move by that much". It is not the whole of a zoom
591
+ about the pointer: that needs the point that must _not_ move (`ev.x`/`ev.y`)
592
+ and the modifier that tells a zoom from a pan, and it answers the wheel
593
+ whether or not anything "can scroll". `defaultWheel(ev)` is the whole event,
594
+ at the node under the pointer, before the chain walks; `ev.preventDefault()`
595
+ consumes it and the walk never runs. The deltas are as the device measured
596
+ them, fractions included — the whole-pixel rule the chain follows is there
597
+ for the scroll blit, and a zoom factor is continuous.
598
+
599
+ ```js
600
+ defaultWheel(ev) {
601
+ if (!ev.ctrlKey) return; // an unmodified wheel still scrolls
602
+ this.zoomAbout(ev.x, ev.y, Math.exp(-ev.deltaY / 400));
603
+ ev.preventDefault(); // …and this one was mine
604
+ }
605
+ ```
606
+
607
+ **Focus is a prerequisite, not a consequence.** Keys are delivered to the
608
+ focused node, and nothing focuses an element that has not said it can be:
609
+ `focusableByDefault = true` makes it a tab stop and a press target the way
610
+ `<textinput>` is one, and an application's `focusable={false}` or `tabIndex`
611
+ still overrides it ([events.md](events.md#focus)).
612
+
613
+ **Tab is an ordinary key here.** It reaches `defaultKeyDown` before focus
614
+ traversal sees it, so an element can keep it — an editor indenting with Tab
615
+ does not need a wrapping component to get it, which is what the ordering used
616
+ to force. Consuming it means calling `ev.preventDefault()` from the default
617
+ action: what that prevents is the default action _after_ this one, which for
618
+ Tab is the focus cycle.
619
+
620
+ **`super.defaultKeyDown(ev)` is the base class's own behaviour**, and there
621
+ are two things in it: the text-selection keys, and Space/Enter dispatching
622
+ the node's `onClick` as a click
623
+ ([events.md](events.md#space-and-enter-are-a-click)). An override that
624
+ answers the keystroke itself — an editor, where Space types a space — simply
625
+ does not call it, which is what `<textinput>` does. One that answers a few
626
+ keys and leaves the rest, as the scroll pane does, ends its `default:` branch
627
+ with `return super.defaultKeyDown(ev)` and keeps both.
628
+
629
+ An element that eats Tab has taken the keyboard user's only way out of it, so
630
+ it owes them another. The convention, above and worth converging on: **Escape
631
+ arms one pass-through Tab.** Escape on its own does nothing visible, the next
632
+ Tab leaves, and the one after that indents again — the same bargain a code
633
+ editor on the web makes, and the reason a screen-reader user is not stuck in
634
+ your element.
635
+
636
+ **An element that types text implements `defaultComposition` too.** `é` is
637
+ a dead key and then a letter, and the keys that make it never reach
638
+ `defaultKeyDown` — so an element that only implements the key path types the
639
+ letter and drops the accent, which is the bug
640
+ [events.md](events.md#composition) describes. Three phases, and the third
641
+ is the only one that inserts anything:
642
+
643
+ ```js
644
+ defaultComposition(ev) {
645
+ if (ev.type === 'compositionEnd') {
646
+ this.preedit = '';
647
+ if (ev.data) this.insert(ev.data); // one edit, one undo entry
648
+ } else {
649
+ this.preedit = ev.data; // draw it at the caret, underlined
650
+ }
651
+ this.root?.invalidate(false, this, 'text');
652
+ }
653
+ ```
654
+
655
+ **An element that forwards keys somewhere else sets `composes = false`**
656
+ instead. Composition is ours to run only when the text lands in our tree;
657
+ an element handing the raw key event to another program — `<foreign>` and
658
+ its embedded client — wants the dead key itself, and the client's own input
659
+ method takes it from there.
660
+
661
+ The preedit is deliberately not part of your value: it never reaches
662
+ `onChange` or the undo history, a controlled value rewritten mid-composition
663
+ cannot corrupt it, and abandoning it — Escape, focus leaving — is dropping a
664
+ string rather than undoing an edit. Not implementing it at all is a
665
+ supported position for an element that holds no text; the composition then
666
+ simply goes nowhere.
667
+
668
+ **A caret blinks at `CARET_BLINK_MS`.** It is the cadence `<textinput>` uses,
669
+ exported so that two carets on one screen are in step rather than a few tens
670
+ of milliseconds apart. Stop the timer in `defaultBlur` _and_ in
671
+ `destroySubtree`: a node that unmounts while focused is forgotten rather than
672
+ blurred, so `defaultBlur` is not guaranteed to run.
673
+
674
+ ### Text a screen reader can read
675
+
676
+ An element that draws text of its own is, to an assistive technology,
677
+ nothing at all: a painted rectangle with no characters, no caret and no
678
+ selection in it. `<textinput>` is not — it implements AT-SPI's `Text` and
679
+ `EditableText` interfaces in full — and the difference is one method
680
+ (issue #257):
681
+
682
+ ```js
683
+ class EditorNode extends Node {
684
+ a11yTextState() {
685
+ return {
686
+ value: this.lines.join('\n'), // what is drawn
687
+ caret: this.caret, // code points, into `value`
688
+ selectionStart: this.anchor,
689
+ selectionEnd: this.caret,
690
+ editable: true,
691
+ multiline: true,
692
+ };
693
+ }
694
+
695
+ insert(text) {
696
+ // …the element's own edit…
697
+ this.notifyA11yTextChanged();
698
+ }
699
+ }
700
+ ```
701
+
702
+ That alone buys the whole read side: character count, reading by character,
703
+ word and line, the caret, the selection, and the `text-changed` /
704
+ `text-caret-moved` / `text-selection-changed` deltas Orca narrates from —
705
+ through the same code that serves `<textinput>`, so a third-party editor is
706
+ read by the paths core is read by rather than by a parallel set of them.
707
+
708
+ | member | when |
709
+ | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
710
+ | `a11yTextState()` | always, for an element holding text. `null` for one that holds none |
711
+ | `notifyA11yTextChanged()` | every edit, caret move, selection change and composition — the state is _pulled_ when this says it moved |
712
+ | `a11ySetSelection(start, end)` | an AT moved the caret or selected a range; the caret belongs at `end`. Without it the element is read but not navigated |
713
+ | `a11yReplaceText(start, end, text)` | an AT edited: replace that range. Insert, delete and set-the-lot all arrive here. Without it the element is read but not typed in |
714
+ | `a11yRole` | the ARIA role this element is when the app writes none — `'textbox'`, `'log'`, `'document'`. Assign it in the constructor |
715
+
716
+ **The two tiers are the two kinds of element.** A viewer — a markdown
717
+ document, a code block, a log, a terminal — reports `value` and a selection
718
+ and stops there: it gets the `Text` interface, a `document` role, and a
719
+ selection an AT can read and set, which is the whole of what "select this
720
+ paragraph and copy it" needs. An editor adds `editable: true` and
721
+ `a11yReplaceText`, which is what turns on the EDITABLE state and the
722
+ `EditableText` interface. An element that claims `editable` without
723
+ implementing the write **is** still announced as editable and reports its
724
+ own edits — it simply is not exposed as one an AT may type into, because an
725
+ interface whose every method answered "no" is a lie an AT cannot see
726
+ through.
727
+
728
+ Four details, each of which is a defect the other way round:
729
+
730
+ **Offsets are code points and they index what you draw.** `Array.from(value)`
731
+ is the counting, and `value` is the string on the screen — an open
732
+ composition included, because every geometric answer beside it (the extents
733
+ of character _n_, a magnifier's highlight) is read off the glyphs a sighted
734
+ user sees. Say which part is uncommitted with `preedit: { offset, text }`
735
+ and its churn is marked as text the user did not type, exactly as
736
+ `<textinput>`'s is ([accessibility.md](accessibility.md#while-a-composition-is-open)).
737
+ Offsets you report are clamped for you, so a caret left stale between an
738
+ edit and your own bookkeeping cannot become an out-of-range answer on the
739
+ wire.
740
+
741
+ **`notifyA11yTextChanged()` is free when nobody is listening** — one
742
+ property read, the hook slots being empty until a bridge or the test spy
743
+ fills them. So call it from every path that moves the text, rather than
744
+ asking whether accessibility is on.
745
+
746
+ **`a11yTextState()` is called several times per change**, for the role, the
747
+ states and the diff. Answer from state the element already holds; it must
748
+ not shape, copy a buffer, or invalidate.
749
+
750
+ **Say `multiline`.** Left unsaid, neither the single-line nor the
751
+ multi-line state is claimed — better than a guess read off the current
752
+ value, which would report the element's shape _changing_ the first time
753
+ somebody pressed Enter.
754
+
755
+ The behaviour is assertable without a screen reader, a desktop or a bus:
756
+ `renderX11(el, { a11y: true })` hands back a spy that records the same feed
757
+ ([accessibility.md](accessibility.md#asserting-what-a-screen-reader-would-hear)),
758
+ and `test/a11y-custom-text.test.js` in this repo is a worked editor and a
759
+ worked viewer driven through it.
760
+
761
+ ### A scene a screen reader can walk
762
+
763
+ _Issue #304._ An element that draws a hundred interactive things is still
764
+ one node, so it is one accessible: a graph pane full of selectable,
765
+ draggable, connectable nodes reads out as **"Flow graph, group"** and stops
766
+ there. Nothing in it can be found, named, activated or told apart, and no
767
+ amount of `role`/`aria-*` on the element itself changes that — the objects
768
+ an assistive technology would need are not in the tree, because you drew
769
+ them.
770
+
771
+ Describe them and they are:
772
+
773
+ ```js
774
+ class FlowNode extends Node {
775
+ a11yScene() {
776
+ return this.nodes.map((node) => ({
777
+ id: node.id, // stable for as long as it is on screen
778
+ role: 'listitem',
779
+ name: node.label,
780
+ rect: node.rect, // window coordinates, like `abs`
781
+ states: {
782
+ selected: this.selection.has(node.id),
783
+ focused: this.cursor === node.id,
784
+ },
785
+ }));
786
+ }
787
+
788
+ select(id) {
789
+ // …the element's own selection…
790
+ this.notifyA11ySceneChanged();
791
+ }
792
+ }
793
+ ```
794
+
795
+ Each becomes a real child of your element's accessible: named, placed,
796
+ walked into, focusable, with `selected` announced as it changes — the same
797
+ model a `<box role="listitem" aria-selected>` goes through, because that is
798
+ literally what these are read as. `id` is the whole of what core needs from
799
+ you: it is what makes the child that was there last frame the same child
800
+ this frame, and therefore what keeps every reference a screen reader is
801
+ holding alive across a scene you rebuild sixty times a second.
802
+
803
+ | member | when |
804
+ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
805
+ | `a11yScene()` | always, for an element that draws interactive things. Called once per question about your children — a cheap read, never a pass |
806
+ | `notifyA11ySceneChanged()` | the scene moved between commits: a drag, an animation, your own arrow keys. A commit re-reads it on its own |
807
+ | `a11ySceneAction(id, action)` | an AT acted on one of them. Return `true` to claim it; anything else keeps core's answer |
808
+
809
+ What one item may say:
810
+
811
+ | field | |
812
+ | ---------------------- | -------------------------------------------------------------------------------------------------------------------- |
813
+ | `id` | required. Its name for as long as it is drawn — ids are matched within their parent, not globally |
814
+ | `rect` | required. Where it is, in window coordinates: where AT focus is drawn and what a magnifier follows |
815
+ | `role` | an ARIA role. `'group'` if it says none |
816
+ | `name` / `description` | what a screen reader says. An unnamed item announces as having none, which is the defect being loud |
817
+ | `states` | `selected`, `checked`, `expanded`, `disabled`, `busy`, and `focused` for your own keyboard cursor |
818
+ | `focusable` | default true |
819
+ | `props` | anything else an element carries — `aria-posinset`/`aria-setsize` for Orca's "3 of 7", `aria-level`, `aria-valuenow` |
820
+ | `children` | a scene with structure: series and points, groups and nodes |
821
+
822
+ Four things worth knowing before you write one:
823
+
824
+ **`focused` is the only way your cursor is reported.** The window's focus
825
+ manager holds your element, not the thing inside it that arrow keys move —
826
+ which is right, and it means the state has to come from you. Reporting it
827
+ is all that is asked: navigation between items stays yours.
828
+
829
+ **Actions fall back rather than fail.** With no `a11ySceneAction`, an
830
+ activation is a synthetic click at the item's own rect — you hit-test your
831
+ scene already, so the rect is the whole address and an AT's click is
832
+ indistinguishable from a mouse user's. `focus` focuses the element,
833
+ `scroll` reveals it. Implement the seam for the cases where that is wrong
834
+ (an activation that is not a click, a cursor to move, a viewport of your
835
+ own to scroll), claim those with `true`, and let the rest fall through.
836
+
837
+ **An action needs a role that promises one** — `button`, `option`,
838
+ `treeitem`, `checkbox` — _or_ your `a11ySceneAction`, which is the same
839
+ promise made directly. A `listitem` with neither is read, not clicked, the
840
+ same as everywhere else in ARIA.
841
+
842
+ **`a11yScene()` is called once per question**, so answer from what you
843
+ already hold. It is the same rule `a11yTextState()` follows, for the same
844
+ reason: a shaping pass or a copy of your model behind it turns a screen
845
+ reader walking the tree into a frame budget.
846
+
847
+ Assertable with no bus and no desktop, through the same spy:
848
+ `renderX11(el, { a11y: true })` records an item's states changing exactly as
849
+ it records a `<Checkbox>`'s, and `test/a11y-scene.test.js` is a worked graph
850
+ pane. The wire itself — children, extents, actions — is covered in
851
+ `test/atspi.test.js`.
852
+
853
+ ### The standard edit menu
854
+
855
+ A `<textinput>` gets a right-click Undo / Cut / Copy / Paste / Select All
856
+ menu without being asked. An element that edits or selects text of its own
857
+ gets **the same menu** — not one that looks like it — by naming its verbs
858
+ and letting core do the rest (issue #256):
859
+
860
+ ```js
861
+ import { openEditMenu } from 'react-x11';
862
+
863
+ class EditorNode extends Node {
864
+ defaultContextMenu(ev) {
865
+ openEditMenu(
866
+ this,
867
+ { x: ev.x, y: ev.y },
868
+ {
869
+ canUndo: this.canUndo,
870
+ undo: () => this.undo(),
871
+ canRedo: this.canRedo,
872
+ redo: () => this.redo(),
873
+ hasSelection: !this.selection.isEmpty(),
874
+ cut: () => this.cut(),
875
+ copy: () => this.copy(),
876
+ paste: () => this.paste(),
877
+ selectAll: () => this.selectAll(),
878
+ },
879
+ );
880
+ }
881
+ }
882
+ ```
883
+
884
+ What that buys, and the reason it is worth an export rather than a recipe:
885
+ the rows and their order, the enablement rules, Paste watching **selection
886
+ ownership** instead of asking the server on the way to opening a menu
887
+ ([elements.md](elements.md#the-right-click-menu)), the arrow keys and
888
+ Escape, the pointer grab that dismisses it, and handing the keyboard back to
889
+ wherever it came from afterwards. `<textinput>` is a caller of this function,
890
+ which is what keeps the two from drifting.
891
+
892
+ **A verb you leave out is a row that is not there** — not a greyed one. It
893
+ was `<textinput sensitive>` that motivated the rule, where a disabled Copy
894
+ over a password reads as a bug in the application rather than as a decision,
895
+ but it is also exactly what a **read-only** surface needs: a rendered
896
+ document that can be selected and copied passes three things and gets a
897
+ two-row menu, with no dead Undo, Cut or Paste to explain.
898
+
899
+ ```js
900
+ openEditMenu(
901
+ node,
902
+ { x, y },
903
+ {
904
+ hasSelection: selection.hasSelection(),
905
+ copy,
906
+ selectAll,
907
+ },
908
+ );
909
+ ```
910
+
911
+ Leave out every verb and nothing opens at all.
912
+
913
+ **Enablement is not yours to decide row by row.** You answer three questions
914
+ only the target can answer — `canUndo`, `canRedo`, `canSelectAll` (which
915
+ defaults to true: the surface is empty, or all of it is selected already) —
916
+ and hand over `hasSelection`. Cut and Copy follow the selection, Paste
917
+ follows what the server has said about the clipboard. That is the part a
918
+ second implementation would get subtly wrong.
919
+
920
+ `at` is where the pointer was, in the **owner window's** coordinates — the
921
+ `x`/`y` a synthetic event carries. A surface with no caret has nothing else
922
+ to offer, and this is what it already has; the menu is placed on the screen
923
+ from there, clamped into the monitor it opens on.
924
+
925
+ Two more functions come with it, both for the element rather than the menu:
926
+
927
+ | function | |
928
+ | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
929
+ | `editMenuOpen(node)` | is the menu up? An element that paints a selection asks — the popup holds the keyboard, so the element is not focused, and the selection has to stay lit |
930
+ | `closeEditMenu(node)` | close it. The menu already closes itself on a choice, Escape and a press outside; this is for content that changed underneath it, or a surface that scrolled |
931
+
932
+ They are exported from the **package root** rather than from
933
+ `react-x11/node`, because the caller is not always an element: a component
934
+ that wraps one opens the same menu from an `onContextMenu` handler and a ref.
935
+
936
+ ### Scrolling content you painted
937
+
938
+ A `<box overflow="scroll">` scrolls **children**: layout knows where they
939
+ are, so the extent, the bars, the clamping and the wheel all follow from the
940
+ tree. An editor, a terminal or a canvas-backed table has no children — its
941
+ content is pixels it draws — and it still has a scroll position, an extent
942
+ and a wheel gesture to answer. Two methods make it a member of the same
943
+ machinery instead of an exception to it (issue #253):
944
+
945
+ | method | |
946
+ | ------------------------ | --------------------------------------------------------------------------------------------------- |
947
+ | `canScroll(dx, dy)` | is there room to move on the axis this delta names? The wheel asks before it scrolls you |
948
+ | `scrollBy(by)` | `scrollBy(dy)` or `scrollBy({x, y})` — move by that much |
949
+ | `measureScrollContent()` | `Scrollable` only: `{ width, height }`, how far the content reaches. The default walks the children |
950
+
951
+ **The wheel's default action is those first two and nothing else.** It walks
952
+ out from the node the pointer hit, asks each one `canScroll(deltaX, deltaY)`,
953
+ and the first that says yes gets `scrollBy` — up to the `<window>`, where the
954
+ walk stops. So an element joins the chain by answering two questions, and it
955
+ **chains** by answering the first of them honestly: say no when there is
956
+ nothing left to move and the wheel goes to the pane or the window behind you,
957
+ which is what a browser does and what a user flicking through a long page
958
+ expects. `<textarea>` is the worked example in core — it scrolls wrapped text
959
+ it painted, and since it answers `canScroll` off that text, a short one hands
960
+ the gesture outward rather than swallowing it.
961
+
962
+ The one thing ahead of the walk is the hit node's own `defaultWheel`
963
+ ([above](#behaviour-of-your-own)) — for an element whose wheel is not a
964
+ scroll at all, a pane that zooms about the pointer. Consuming the event there
965
+ is what keeps the walk from running; leaving it alone is what puts the
966
+ element back in this chain.
967
+
968
+ Answer `canScroll` from the **extent, not the position**: a viewport already
969
+ scrolled to its bottom should keep the rest of a flick rather than pass it
970
+ to whatever is behind it.
971
+
972
+ Both deltas are **whole pixels**, and stay whole however the scroll was
973
+ measured. A touchpad on a server with XI2 reports fractions of a notch and
974
+ `ev.deltaY` carries them ([events.md](events.md#wheel)), but the default
975
+ action spends only the whole part and carries the rest to the next event —
976
+ because a fractional scroll offset is one the server-side blit cannot shift,
977
+ and a smooth gesture that repainted every frame would cost more than it
978
+ looks. So an element joining the chain never has to think about sub-pixels,
979
+ and a slow scroll still moves: a pixel at a time.
980
+
981
+ #### The mixin, for everything else
982
+
983
+ `canScroll` + `scrollBy` buys the wheel. `Scrollable(Node)` buys the rest —
984
+ the offsets, `scrollTo`/`scrollBy`/`scrollIntoView`, the drawn scrollbars and
985
+ their drags, the scroll keys (arrows, Page, Home/End, Space), the tab stop
986
+ and the AT-SPI scroll-pane role — and all of it reads one number pair:
987
+
988
+ ```js
989
+ import { Node, Scrollable } from 'react-x11/node';
990
+
991
+ const LINE = 16;
992
+
993
+ class EditorNode extends Scrollable(Node) {
994
+ constructor(props, app) {
995
+ super('codeeditor', props, app);
996
+ }
997
+
998
+ // scrolling is what this element *is*, rather than something an app opts
999
+ // into with a style; the default answers `overflow: 'scroll'`
1000
+ isScroller() {
1001
+ return true;
1002
+ }
1003
+
1004
+ // …and the one thing only this element knows: how far the drawing goes
1005
+ measureScrollContent() {
1006
+ return {
1007
+ width: this.longestLineWidth(),
1008
+ height: LINE * this.lines.length,
1009
+ };
1010
+ }
1011
+
1012
+ paintContent(ctx) {
1013
+ const box = this.contentBox();
1014
+ ctx.save();
1015
+ ctx.beginPath();
1016
+ ctx.rect(box.x, box.y, box.width, box.height);
1017
+ ctx.clip();
1018
+ this.drawLines(ctx, box.x - this.scrollX, box.y - this.scrollY);
1019
+ ctx.restore();
1020
+ }
1021
+ }
1022
+ ```
1023
+
1024
+ Three things about that, each of which is a bug if you get it the other way
1025
+ round:
1026
+
1027
+ **Draw in `paintContent`, not in `paint`.** `paint` draws the background,
1028
+ then `paintContent`, then the children, the border, and — for a
1029
+ `Scrollable` — the scrollbars last. An element that draws after
1030
+ `super.paint(ctx)` returns therefore paints _over its own thumb_.
1031
+ `paintContent` is the seam every built-in that draws uses, and it is where a
1032
+ drawing belongs whether it scrolls or not.
1033
+
1034
+ **Offset the drawing yourself, and clip it.** A `<box>`'s children are moved
1035
+ by the scroll during layout; nobody moves pixels. `contentBox()` is the
1036
+ viewport to clip to and `scrollX`/`scrollY` are what to subtract — they are
1037
+ already clamped to the extent you reported, so there is nothing to bound
1038
+ again. `scrollX` is a distance from the edge the content _starts_ at, which
1039
+ is the right-hand one under `direction: 'rtl'`
1040
+ ([styling.md](styling.md#direction-and-the-logical-edges)) — and the `width` you report is that
1041
+ same reach, so an element that draws in its own reading direction never has
1042
+ to ask which one it is in.
1043
+
1044
+ **Do not assign `focusableByDefault`.** `Scrollable` answers it — a pane with
1045
+ somewhere to go is a tab stop, one that fits is not — and assigning over the
1046
+ getter throws. Override the getter if your element is focusable for a reason
1047
+ of its own.
1048
+
1049
+ `measureScrollContent` is called at most once per layout pass, from
1050
+ `absolutize`, so it may read layout geometry, but it must not paint or
1051
+ invalidate. It has to return finite numbers: a `NaN` extent would otherwise
1052
+ become a `NaN` offset and a subtree laid out at `NaN`, so returning anything
1053
+ else throws naming the element, exactly as `measureContent` does. The answer
1054
+ is cached across passes that laid nothing inside the pane out again — a pass
1055
+ that merely scrolled reuses it, since a scroll cannot change how far the
1056
+ content reaches.
1057
+
1058
+ When the drawing changes its own extent — a line was typed, rows arrived —
1059
+ say so the way any other layout change is said, with
1060
+ `this.invalidate(true, this, 'scroll')`; the next pass asks again. That call
1061
+ is also what re-arms the measurement above: content that grows silently and
1062
+ never invalidates would scroll against the stale extent.
1063
+
1064
+ #### Scrolling the pixels, not just the offset
1065
+
1066
+ `scrollBy` moves a number; nobody moves pixels. For a drawing that is cheap
1067
+ to redraw, that is the end of it — `paintContent` runs again at the new
1068
+ offset and the frame is correct. For one that is expensive per row and
1069
+ already lives in a retained buffer — a terminal grid, a log view, a minimap,
1070
+ a chart that pans — a scroll changes almost nothing: the band that stays
1071
+ visible is the same pixels, one shift away. A `Surface` can move that band
1072
+ itself, server-side:
1073
+
1074
+ ```js
1075
+ import { Node, Scrollable } from 'react-x11/node';
1076
+ import { Surface } from 'react-x11/ntk';
1077
+
1078
+ class GridNode extends Scrollable(Node) {
1079
+ ensureSurface(width, height) {
1080
+ if (this.surface?.width === width && this.surface?.height === height)
1081
+ return;
1082
+ this.surface?.destroy();
1083
+ this.surface = new Surface(this.app, { width, height });
1084
+ this.drawRows(this.visibleRows()); // a new buffer starts transparent
1085
+ }
1086
+
1087
+ scrollBy(by) {
1088
+ const from = this.scrollY;
1089
+ super.scrollBy(by);
1090
+ // ask *after*: the scroller clamped it to the extent you reported
1091
+ const dy = from - this.scrollY;
1092
+ if (!dy || !this.surface) return;
1093
+ const all = {
1094
+ x: 0,
1095
+ y: 0,
1096
+ width: this.surface.width,
1097
+ height: this.surface.height,
1098
+ };
1099
+ if (this.surface.copyWithin(all, 0, dy)) this.drawRows(this.exposedBy(dy));
1100
+ else this.drawRows(this.visibleRows());
1101
+ }
1102
+
1103
+ paintContent(ctx) {
1104
+ const box = this.contentBox();
1105
+ ctx.drawImage(this.surface, box.x, box.y);
1106
+ }
1107
+
1108
+ destroySubtree() {
1109
+ this.surface?.destroy();
1110
+ super.destroySubtree();
1111
+ }
1112
+ }
1113
+ ```
1114
+
1115
+ `copyWithin(src, dx, dy)` shifts `src` (`{x, y, width, height}`, in surface
1116
+ coordinates) in place by an integer delta and answers **whether anything
1117
+ survived** — `false` means repaint the lot, which is the branch a fresh
1118
+ buffer, a jump to the far end and a resize all take. It is one `CopyArea`
1119
+ inside the pixmap: nothing crosses the wire but the request, the overlap is
1120
+ safe, and no exposure events come back.
1121
+
1122
+ The same element runs unchanged on the Cocoa, Wayland and win32 backends.
1123
+ `react-x11/ntk`'s `Surface` asks the app it is handed for the
1124
+ implementation — ntk's pixmap on an X connection, a CG bitmap on a Cocoa
1125
+ app, a GL render target on a Wayland one, a Direct2D bitmap on a win32 one
1126
+ — so the surface above is a bitmap there, `copyWithin` is one in-place copy
1127
+ of the band (on win32 it answers `false` for now, the bridge having no
1128
+ in-place blit for an offscreen bitmap, so the element repaints the rect),
1129
+ and `drawImage` is one composite, with the element naming no backend. The
1130
+ one thing to know: where a surface has one graphics state rather than a context per
1131
+ call — a bitmap, a render target — `getContext('2d')` answers the same
1132
+ context every time and its `destroy()` is a no-op (docs/macos.md "Custom
1133
+ drawing on a layer tree").
1134
+
1135
+ **Drawing into a surface, whichever backend.** ntk's advice — a caller
1136
+ doing many draws takes `getContext('2d')` once and holds it, rather than
1137
+ one context per frame — holds everywhere, including the backends where the
1138
+ surface is a GPU target and every context in the process shares one GL
1139
+ device. A held context there takes the device back whenever it finds it in
1140
+ someone else's hands, so its draws land in its own surface even in the
1141
+ middle of the window's own paint, and the window's context does the same on
1142
+ its way back (issue #566). Nothing to branch on, and nothing to restore:
1143
+
1144
+ ```js
1145
+ drawRows(rows) {
1146
+ const ctx = (this.ctx ??= this.surface.getContext('2d'));
1147
+ for (const row of rows) this.paintRow(ctx, row);
1148
+ }
1149
+ ```
1150
+
1151
+ `surface.render(fn)` is the scoped alternative: it hands `fn` a context
1152
+ whose transform and clip start clean and flushes when `fn` returns. Use it
1153
+ for a whole-surface repaint; hold a context for a grid that is touched a
1154
+ few cells at a time. The one thing not to do is mix them and expect the
1155
+ held context's transform to survive a `render()` — it is the same context,
1156
+ and `render` resets it.
1157
+
1158
+ If the surface is **opaque** — every pixel of it written, which a terminal's
1159
+ grid or a tiled scene is — say so, and the present stops blending:
1160
+
1161
+ ```js
1162
+ paintContent(ctx) {
1163
+ const box = this.contentBox();
1164
+ ctx.globalCompositeOperation = 'copy';
1165
+ ctx.drawImage(this.surface, box.x, box.y);
1166
+ ctx.globalCompositeOperation = 'source-over';
1167
+ }
1168
+ ```
1169
+
1170
+ `copy` is canvas's "replace these pixels, do not read what is under them",
1171
+ and it means the same thing on both backends: XRender's `PictOp.Src` on
1172
+ X11, and on Cocoa a row memcpy in place of the CGImage the blend would
1173
+ otherwise be built from — a 125x45 terminal grid composites in 0.42ms
1174
+ rather than 1.37 (docs/macos.md "Compositing a surface at a translate").
1175
+ Set it back, or set it inside a `save`/`restore` pair: it is context state,
1176
+ and the context is the window's. It is only right for a surface with no
1177
+ transparency to show through, and it is refused rather than approximated by
1178
+ a backend that cannot draw it — assign it and read the property back if you
1179
+ want to know which you got.
1180
+
1181
+ Two things it lines up with. The deltas you are handed are already whole
1182
+ pixels — the sub-pixel carry described above exists so that a shift is
1183
+ always expressible — so an element scrolling this way never has a fraction
1184
+ to round. And `<box overflow="scroll">` has been doing the window-side half
1185
+ of this without being asked: a pure scroll of one viewport blits the
1186
+ surviving band inside ntk's backing pixmap rather than repainting it. This
1187
+ is that optimisation, for a buffer you retained yourself.
1188
+
1189
+ An element that draws its content live rather than into a surface asks for
1190
+ the window-side half directly, with
1191
+ [`scrollContents`](#panning-a-scene-you-drew) — the same shift, one layer
1192
+ out.
1193
+
1194
+ #### Drawing what stays put while the pane scrolls
1195
+
1196
+ That window-side blit copies everything in the pane's viewport with its
1197
+ content, which is right for everything that scrolls. An element that
1198
+ paints part of itself **against the pane's viewport** rather than against
1199
+ its own box has pixels the copy gets wrong: a document with a fixed
1200
+ background or a `position: fixed` header, drawn by one element inside a
1201
+ `<box overflow="scroll">`, had its header dragged along with the text and
1202
+ its background scrolling away. `viewportFixedRects()` is the element's word
1203
+ for those parts: window coordinates, device pixels, like `abs`, or `null`,
1204
+ the default.
1205
+
1206
+ ```js
1207
+ class DocumentNode extends Node {
1208
+ _viewport() {
1209
+ // the nearest scroll pane: the one whose scroll the answer is about
1210
+ let pane = this.parent;
1211
+ while (pane && !pane.isWindow && !pane.isScroller()) pane = pane.parent;
1212
+ return pane && !pane.isWindow ? pane.contentBox() : null;
1213
+ }
1214
+
1215
+ viewportFixedRects() {
1216
+ const viewport = this._viewport();
1217
+ return viewport && [{ ...viewport, height: HEADER }];
1218
+ }
1219
+
1220
+ paintContent(ctx) {
1221
+ // …the document, at `this.abs`, and the header over it where the
1222
+ // viewport is, from the same geometry the answer came from
1223
+ }
1224
+ }
1225
+ ```
1226
+
1227
+ A scroll of the pane that blits repaints those rects where they are and
1228
+ where the copy dragged their image, as it does the corners of a rounded box
1229
+ around the pane; one that pins most of the viewport — a fixed background
1230
+ behind all of it — makes the scroll a repaint, which draws it right. Only
1231
+ the nearest scroll pane asks: a pane further out moves the inner one with
1232
+ its content, and what is fixed to it with it. The answer is read on each
1233
+ scroll the pane blits, after layout, and nowhere else.
1234
+
1235
+ #### Keeping what the viewport shows while your content moves
1236
+
1237
+ A document laid out again at another width breaks its text above the fold
1238
+ into more lines or fewer, so the paragraph at the top of the viewport moves
1239
+ down the document or up it by the difference, and a pane that keeps its
1240
+ offset shows the reader whatever is at that offset now: a window dragged
1241
+ narrower scrolled them away from where they were reading. A browser keeps
1242
+ the paragraph where it was on screen instead, and moves the offset by as
1243
+ much (CSS Scroll Anchoring 1). `anchorScrollBy(dx, dy)` is how an element
1244
+ asks its scroll pane for that: its content moved by (dx, dy) device pixels
1245
+ under the viewport, in the layout pass running now, and the nearest scroll
1246
+ pane moves its offsets by as much, after it measures what it holds and
1247
+ before it places it, so the frame that lays the content out again shows it
1248
+ where it was.
1249
+
1250
+ ```js
1251
+ class DocumentNode extends Node {
1252
+ measureContent({ width }) {
1253
+ // what is at the top of the viewport, before and after
1254
+ const anchor = this._blockAtViewportTop();
1255
+ const was = anchor?.top;
1256
+ this._layOut(width);
1257
+ if (anchor) this.anchorScrollBy(0, anchor.top - was);
1258
+ return { width, height: this._height };
1259
+ }
1260
+ }
1261
+ ```
1262
+
1263
+ Ask it from the layout pass — `measureContent`, or what it calls — where
1264
+ the pane places what it holds after; shifts asked in one pass add up, and
1265
+ one asked outside a pass waits for the next and asks for it. A `scrollTo`
1266
+ the application made since the last pass drops it, since the shift was
1267
+ measured against the offsets before that scroll; a `scrollIntoView` lands
1268
+ after it; and the result is clamped to what the pane holds, as any scroll
1269
+ is. It returns false where nothing scrolls the element.
1270
+
1271
+ ### Drawing a scene into one node
1272
+
1273
+ _Issue #301._ A frame repaints **rects, not windows**: claims coalesce into
1274
+ disjoint rectangles, each gets its own clipped pass, and a subtree that lands
1275
+ outside the pass being painted emits no drawing at all. All of that has one
1276
+ granularity — the retained node — which is exactly right until your element
1277
+ _is_ the scene. A graph view, a chart, a timeline, a code editor: the tree
1278
+ says "one node, 1100×700", so a pass over the 80×40 box a dragged node moved
1279
+ through redraws all three hundred nodes, all seven hundred edges, the grid
1280
+ and the minimap into a clip that throws almost all of it away.
1281
+
1282
+ Two accessors make such an element a member of the same machinery rather
1283
+ than the one exception to it. Both are opt-in, and both keep core's answer
1284
+ when you say nothing.
1285
+
1286
+ **Painting: `paintDamage()`.** The rect this pass covers, in the owning
1287
+ window's coordinates — the same space as `abs`, in device pixels — or `null`
1288
+ when the frame is unbounded and everything has to be drawn. Cull
1289
+ against it the way core culls the tree, where a pixel short of the rect
1290
+ still counts as inside it: antialiasing puts ink just past the bounds you
1291
+ computed, and the pass is clipped to the rect, so drawing a near miss costs
1292
+ one draw and loses nothing. Here `overlaps(a, b, 1)` asks whether two rects
1293
+ come within a pixel of each other:
1294
+
1295
+ ```js
1296
+ class FlowNode extends Node {
1297
+ paintContent(ctx) {
1298
+ // null outside a paint and on a full pass, and both mean the same
1299
+ // thing: nothing bounds you, draw the lot
1300
+ const damage = this.paintDamage();
1301
+ for (const cell of this.gridCells()) {
1302
+ if (damage && !overlaps(cell, damage, 1)) continue;
1303
+ this.drawCell(ctx, cell);
1304
+ }
1305
+ for (const edge of this.edges) {
1306
+ if (damage && !overlaps(edge.routedBounds, damage, 1)) continue;
1307
+ this.drawEdge(ctx, edge);
1308
+ }
1309
+ // …nodes, minimap, controls
1310
+ }
1311
+ }
1312
+ ```
1313
+
1314
+ **Committing: `selfDamagedProps`.** `applyProps` damages the whole node when
1315
+ any non-event prop changes identity, which is right for every element whose
1316
+ node _is_ what changed and always-everything for a scene pane. A controlled
1317
+ graph app commits a new `nodes` array per drag step, so each step is one
1318
+ scoped claim from the gesture plus one full-pane claim from the commit — and
1319
+ the full-pane claim wins. Name the props whose damage your own `applyProps`
1320
+ claims and the commit contributes none of its own:
1321
+
1322
+ ```js
1323
+ registerElement('flow', {
1324
+ create: (props, app) => new FlowNode(props, app),
1325
+ semanticNames: ['nodes', 'edges'],
1326
+ // "my applyProps diffs these and invalidates what actually moved"
1327
+ selfDamagedProps: ['nodes', 'edges'],
1328
+ });
1329
+ ```
1330
+
1331
+ ```js
1332
+ class FlowNode extends Node {
1333
+ applyProps(next, prev) {
1334
+ const before = prev ?? this.props;
1335
+ super.applyProps(next, prev); // claims nothing for `nodes`
1336
+ if (next.nodes !== before.nodes) {
1337
+ // …so this is the only claim, and it is the box that moved
1338
+ this.invalidate(
1339
+ false,
1340
+ this.movedBounds(next.nodes, before.nodes),
1341
+ 'props',
1342
+ );
1343
+ }
1344
+ }
1345
+ }
1346
+ ```
1347
+
1348
+ When the answer depends on the _values_ rather than the names, override
1349
+ **`paintChanged(next, prev)`** instead — it is what the list feeds, so the
1350
+ two are the same seam:
1351
+
1352
+ ```js
1353
+ paintChanged(next, prev) {
1354
+ if (next.nodes !== prev.nodes && onlyPositionsMoved(next.nodes, prev.nodes)) {
1355
+ return false; // folded in place above, and the moved box is claimed
1356
+ }
1357
+ return super.paintChanged(next, prev);
1358
+ }
1359
+ ```
1360
+
1361
+ Three things about that contract, in the order they bite:
1362
+
1363
+ **Everything you do not recognise goes to `super`.** An element that answers
1364
+ "nothing changed" about a prop it does not actually claim shows a stale
1365
+ pixel, and nothing comes back to fix it — so the override answers for the
1366
+ props it knows and hands the rest to core, which is conservative on purpose.
1367
+ That is what keeps an `aria-label`, a `title`, or a prop the element grows
1368
+ next year correct without anybody remembering this method exists.
1369
+
1370
+ **A style change is not yours to excuse.** `style` is compared by value by
1371
+ the caller and never reaches either seam: what it moves is the background,
1372
+ the border and the clip that `Node.paint` draws for you.
1373
+
1374
+ **Claim before you skip.** `selfDamagedProps` only says the commit will not
1375
+ claim — it does not claim anything on your behalf. An element that names a
1376
+ prop and then forgets to invalidate for it renders nothing at all on that
1377
+ change, which at least fails loudly the first time you drag something.
1378
+
1379
+ And one place `paintDamage()` does not belong: inside `paintCached`
1380
+ ([below](#drawing-once-instead-of-every-frame)). That draws into a surface in
1381
+ its own coordinates, and a copy culled against the window's damage is stored
1382
+ half-drawn under a key that says it is whole — so every later frame that hits
1383
+ the key gets the hole.
1384
+
1385
+ The same scene has a second one-node problem, in the accessible tree rather
1386
+ than in the frame: see [A scene a screen reader can
1387
+ walk](#a-scene-a-screen-reader-can-walk).
1388
+
1389
+ Worth it because the numbers are not small. `@react-x11/components`' `<Flow>`
1390
+ on a 300-node / 745-edge scene at 1100×700 costs ~2470 X requests and ~150 ms
1391
+ per drag step redrawing the pane, and ~400 requests / ~30 ms with the two
1392
+ seams — the same frame, with the parts of it nobody can see left unsent.
1393
+ `REACT_X11_DEBUG_PAINT=1` is how to watch it: the pass's rect is stroked in
1394
+ the frame's colour, so a scene that is still repainting whole strobes across
1395
+ the pane instead of outlining what moved ([debugging.md](debugging.md)).
1396
+
1397
+ #### Panning a scene you drew
1398
+
1399
+ _Issue #303._ The two seams above scope a frame to the part of the scene
1400
+ that changed. A **pan** has no such part: it translates every pixel, so the
1401
+ honest claim is the whole pane and the honest frame is a full repaint — of a
1402
+ picture that is already on screen, one shift away from being right.
1403
+
1404
+ That is the shape `<box overflow="scroll">` has been treating as a special
1405
+ case since issue #138: blit the band that survives inside ntk's backing
1406
+ store, repaint only the strip the shift exposed. `scrollContents` is that
1407
+ same dance, for a viewport of your own:
1408
+
1409
+ ```js
1410
+ class FlowNode extends Node {
1411
+ onDragPan(dx, dy) {
1412
+ this.panX += dx;
1413
+ this.panY += dy;
1414
+ // "the pixels in here moved by (dx, dy); the rest of it is new"
1415
+ this.scrollContents(this.contentBox(), dx, dy);
1416
+ }
1417
+
1418
+ paintContent(ctx) {
1419
+ // …and this is the exposed strip, not the pane. Nothing here changes.
1420
+ const damage = this.paintDamage();
1421
+ for (const item of this.scene()) {
1422
+ if (damage && !overlaps(item.bounds, damage, 1)) continue;
1423
+ this.draw(ctx, item);
1424
+ }
1425
+ }
1426
+ }
1427
+ ```
1428
+
1429
+ The call claims `rect` — the conservative answer, and the one that stands if
1430
+ anything declines — and arms the frame to blit instead. At frame time core
1431
+ asks ntk to move the surviving band and **narrows that claim to the band the
1432
+ shift exposed**, which is what `paintDamage()` then hands your paint. So the
1433
+ element draws the strip and nothing else without ever asking whether the
1434
+ blit happened, and every gate below falls back to repainting `rect`, which is
1435
+ the behaviour without the call at all.
1436
+
1437
+ Four things worth knowing, in the order they bite:
1438
+
1439
+ **`dx`/`dy` are how far the pixels moved.** The sense `Surface.copyWithin`
1440
+ and ntk's `scrollRegion` use, not a scroll offset's: panning the scene right
1441
+ by ten is `dx: 10`, and the exposed band is down the left edge. Both whole
1442
+ device pixels — a fractional shift is not a copy — and `rect` in window
1443
+ coordinates (the same space as `abs` and `contentBox()`: device pixels) and
1444
+ inside your node.
1445
+
1446
+ **You promise one thing: that inside `rect` the frame really is that
1447
+ translation.** Everything else is core's to check, and each of them declines
1448
+ rather than misrenders — a claim from anywhere else reaching into the rect, a
1449
+ sibling drawing over it, a child of your node laid out on top of it, your own
1450
+ border ring or rounded corner, a layout pass that moved something, a region
1451
+ too small or a shift too large to be worth it. Several pans in one frame
1452
+ coalesce into one blit by their net shift; two different regions of one node
1453
+ do not, and fall back.
1454
+
1455
+ **Diagonal is fine here**, unlike the scroll containers' one-axis-at-a-time
1456
+ rule. That rule exists because the L of exposed strips overlaps the scrollbar
1457
+ rects and the merges balloon back towards the whole viewport; with no bars
1458
+ the L is two disjoint rects and stays two. A drag-pan is diagonal almost
1459
+ every frame, so this is the case rather than the corner.
1460
+
1461
+ **Every gate above is about `rect`, not about your node.** A pane usually
1462
+ has furniture — a minimap, zoom controls, a HUD strip — that has to repaint
1463
+ on a pan frame and whose pixels must _not_ ride the blit. A strip along one
1464
+ edge can be carved out of the region you shift and claimed the ordinary way;
1465
+ the claim lands beside the rect and the frame stays a blit:
1466
+
1467
+ ```js
1468
+ onDragPan(dx, dy) {
1469
+ this.panX += dx;
1470
+ this.panY += dy;
1471
+ const box = this.contentBox();
1472
+ const hud = { ...box, y: box.y + box.height - HUD, height: HUD };
1473
+ this.scrollContents({ ...box, height: box.height - HUD }, dx, dy);
1474
+ this.invalidate(false, hud, 'props'); // repainted, not shifted
1475
+ }
1476
+ ```
1477
+
1478
+ A claim that _reaches into_ the rect — by so much as a pixel — still declines
1479
+ the frame (issue #309). Overlay panels mounted as sibling nodes work the same
1480
+ way, as long as they sit outside the rect: one drawing over it is dragged
1481
+ along by the blit, so it declines instead.
1482
+
1483
+ **Furniture in a corner is pinned, not carved.** What shifts is one
1484
+ rectangle, so carving a minimap out of the bottom-right corner takes the rows
1485
+ beside it too, and zoom controls in the other bottom corner make that a band
1486
+ the pane's full width — repainted on every pan frame, with every card, label
1487
+ and edge in it (issue #682). Hand over the whole pane instead, and name the
1488
+ furniture that stays put:
1489
+
1490
+ ```js
1491
+ onDragPan(dx, dy) {
1492
+ this.panX += dx;
1493
+ this.panY += dy;
1494
+ // rects inside the region, in its coordinates, that do not move
1495
+ this.scrollContents(this.contentBox(), dx, dy, null, [
1496
+ this.minimapRect(),
1497
+ this.controlsRect(),
1498
+ ]);
1499
+ }
1500
+ ```
1501
+
1502
+ The copy drags a stale image of each piece along by (dx, dy), and core
1503
+ repaints the pinned rect and that image: two small rects a piece, on every
1504
+ pan frame, whether the furniture changed or not. So what the furniture shows
1505
+ can follow the pan — a minimap's viewport box — without a claim of its own,
1506
+ and a claim it makes anyway is yours as long as it lies inside a pinned rect.
1507
+ A claim anywhere else in the region still declines the frame, and so does
1508
+ furniture that is most of the pane: past three quarters of it repainted, the
1509
+ blit is buying a shift and paying for the region anyway.
1510
+
1511
+ **A rounded box around your element is pinned the same way.** The arc of a
1512
+ rounded corner does not translate, but it lives in the four radius-sized
1513
+ squares at the corners and nowhere else, so core pins those squares itself:
1514
+ the whole region shifts, and each square is repaired where it is and where
1515
+ the copy dragged it — a few pixels a corner (issue #691). A graph pane
1516
+ inside a rounded card is the common shape. Its own border ring and corners
1517
+ are different: those are the element's paint, not its drawing, and they
1518
+ decline the blit.
1519
+
1520
+ The rects a pan frame repaints — its strips, the furniture, the corners —
1521
+ are merged down to a few passes, and merged together, so the small ones
1522
+ join their neighbours before a large box can swallow them: a diagonal pan
1523
+ of a rounded pane with furniture in both bottom corners repaints under a
1524
+ fifth of it.
1525
+
1526
+ A node mounted over the region inside a pinned rect — a zoom button, a
1527
+ legend — is furniture too: it does not decline the blit, and its pixels are
1528
+ repaired with the rest. Its claims carry the pixel of slop every node's do,
1529
+ so the rect to pin is the one it claims, `node.paintBounds()`, or its hover
1530
+ declines the frame it lands in.
1531
+
1532
+ **Unless its pixels are meant to move with it.** A graph whose cards carry
1533
+ real widgets mounts them beside the pane, all in one box it moves with the
1534
+ pan — and that box is over the rect every frame. Name it as a rider and it
1535
+ rides the blit instead of declining it:
1536
+
1537
+ ```js
1538
+ onDragPan(dx, dy) {
1539
+ this.panX += dx;
1540
+ this.panY += dy;
1541
+ // the layer moves by (dx, dy) in this same frame's commit
1542
+ this.scrollContents(this.contentBox(), dx, dy, [this.bodiesLayer]);
1543
+ }
1544
+ ```
1545
+
1546
+ A rider promises what your drawing promises: that it moves by exactly
1547
+ (dx, dy) this frame and changes nothing else. Then its commit claims nothing,
1548
+ the blit carries its pixels with yours, and its move claims only what it
1549
+ leaves or reaches _outside_ the rect — so clip it to your element, with a
1550
+ box around it that paints nothing of its own, and that is nothing at all.
1551
+ Anything else it does — moving by some other amount, a widget in it
1552
+ changing, one mounting as the pan brings it in — is claimed the ordinary
1553
+ way, which declines that frame's blit; the next one blits again.
1554
+
1555
+ **Zoom is not a blit.** Scaling resamples; it is a full repaint and should
1556
+ be. That is the right trade: a zoom is a gesture step, a pan is sixty of them
1557
+ a second.
1558
+
1559
+ The protocol bench prices it at five diagonal pan steps over a 374-cell scene
1560
+ — 627 requests / 308 composites / 0.06 Mpx, against 6550 / 3269 / 2.38 for
1561
+ the same five steps under `REACT_X11_NO_SCROLL_BLIT=1`, which is exactly the
1562
+ fallback every gate here takes. With a HUD strip claimed beside the region
1563
+ they cost 1993 / 993 / 0.43 — the blit plus the strip. With furniture in two
1564
+ bottom corners they cost 1622 / 790 / 0.30 pinned, where carving the band
1565
+ between the corners costs 2736 / 1349 / 0.70, and 1892 / 920 / 0.31 in a
1566
+ rounded pane.
1567
+
1568
+ If your element keeps its drawing in a `Surface` of its own rather than
1569
+ drawing it live, the shift you want is
1570
+ [`copyWithin`](#scrolling-the-pixels-not-just-the-offset) on that surface —
1571
+ same idea, one layer in.
1572
+
1573
+ ### An element that covers its box
1574
+
1575
+ A terminal, a media frame, a chart with a retained surface: an element that
1576
+ composites a bitmap of its own over its whole content box, every paint,
1577
+ leaves nothing of what was painted under it. Core does not know that. A
1578
+ pass inside the element still fills the window's background, the
1579
+ background of every ancestor and the element's own before `paintContent`
1580
+ composites the bitmap over all three — composites the server ran for
1581
+ nothing on X11, and on macOS full-area CoreGraphics passes that were a
1582
+ fifth of a streaming terminal's frame.
1583
+
1584
+ `opaqueRect()` is the element's word for it:
1585
+
1586
+ ```js
1587
+ class TerminalNode extends Node {
1588
+ opaqueRect() {
1589
+ return this.contentBox(); // every pixel of it, alpha one, every paint
1590
+ }
1591
+
1592
+ _screenChanged() {
1593
+ // a rect inside the answer, not the node: a node claim is inflated by
1594
+ // a pixel of slop, which is outside the rect and so never covered
1595
+ this.invalidate(false, this.contentBox(), 'content');
1596
+ }
1597
+ }
1598
+ ```
1599
+
1600
+ A pass that lies inside the answer is painted without the fills that would
1601
+ be under it — the window's background, this node's own and every
1602
+ ancestor's. Whole pixels only, window coordinates like `abs`: a fractional
1603
+ edge is antialiased and an antialiased pixel is not opaque, so core takes
1604
+ the whole pixels inside what you answer. A clipping ancestor shrinks the
1605
+ cover to what reaches the surface, a rounded one by its radius all round.
1606
+ The promise is yours to keep — every pixel, alpha one, on every paint,
1607
+ whatever the props — and an element that is translucent, or paints a
1608
+ background only sometimes, answers `null`, the default. The answer is read
1609
+ per pass, so it may follow `contentBox()`, and it costs nothing on a pass
1610
+ it does not cover.
1611
+
1612
+ **The same promise lets a move be copied** (issue #681). A subtree whose
1613
+ only change in a frame is where it is — a terminal dragged across a
1614
+ desktop, a card of widgets dragged across a graph — is already on the
1615
+ surface, one shift away. When something in it covers its box, nothing under
1616
+ it shows through there, so core copies its pixels where they went
1617
+ (`CopyArea` on X11, the backing's own copy on Cocoa and Windows) and paints
1618
+ only what the copy cannot supply: the strip the move uncovered, the
1619
+ corners a radius gives up, and whatever is drawn over it. What covers is
1620
+ an element answering `opaqueRect()`, a box with a `backgroundColor`
1621
+ nothing shows through, or either of those inside boxes that hold nothing
1622
+ else — the wrappers a card is positioned and scaled by. A card with no background of its own is drawn over what is
1623
+ under it, and is repainted where it was and where it went.
1624
+
1625
+ The frame's other claims are sorted by whose they are. One from an element
1626
+ painted **under** the moved subtree is repainted around the copy but not
1627
+ through it, since nothing of it shows there — a graph pane that claims the
1628
+ box its card moved through, with the card's mounted body over it, pays for
1629
+ the card's header and edges and not for the body. One from inside the
1630
+ subtree, over it, or from a claim core cannot place is repainted in full,
1631
+ and where the copy carried what it covered. When those repaint most of the
1632
+ copy, or would take more damage rects than the window affords a frame —
1633
+ on X11, four: a rounded card's corners and the strip it uncovered are
1634
+ already five — the move is repainted where it was and where it went, as
1635
+ it always was. `REACT_X11_NO_SCROLL_BLIT=1` turns the copy off with the
1636
+ other blits ([debugging.md](debugging.md#react_x11_no_scroll_blit1)).
1637
+
1638
+ ### An element that draws past its box
1639
+
1640
+ Core claims a node's damage, culls it out of a pass and sizes a fade's
1641
+ surface by its box — grown by its outline and its shadow, the two things a
1642
+ box draws outside itself. An element that draws anything else out there has
1643
+ to say how far, or it is claimed by its box alone: when it changes, the old
1644
+ ink's edge outside the box stays on the screen and the new one's is not
1645
+ drawn, and scrolled just out of a pane it drops the ink it still puts in.
1646
+
1647
+ ```js
1648
+ class RichTextNode extends Node {
1649
+ // a code chip is padded two pixels past the run behind it, and at the
1650
+ // start of a line that is outside the box
1651
+ paintOverhang() {
1652
+ return Math.round(2 * this.scale);
1653
+ }
1654
+ }
1655
+ ```
1656
+
1657
+ Device pixels, every side. It is read when core rebuilds its cached paint
1658
+ bounds, which a claim of the node does, so answer a bound for anything the
1659
+ element may draw rather than for what it drew last — the claim after a
1660
+ change is what has to cover the frame before's ink.
1661
+
1662
+ ### Drawing once instead of every frame
1663
+
1664
+ A drawing that does not change between frames does not have to be redrawn
1665
+ between frames. Implement two more methods and your element is rendered once
1666
+ and composited on later repaints, sharing one rendered copy with every other
1667
+ node that produces the same key:
1668
+
1669
+ | method | |
1670
+ | ----------------------- | ----------------------------------------------------------------------------- |
1671
+ | `paintCachePlan()` | `null` to opt out this frame, or `{ key, x, y, width, height, format, tint }` |
1672
+ | `paintCached(ctx, box)` | draw, at the **origin of `box`** — not at `this.abs` |
1673
+
1674
+ ```js
1675
+ paintCachePlan() {
1676
+ const width = Math.round(this.abs.width);
1677
+ const height = Math.round(this.abs.height);
1678
+ if (width <= 0 || height <= 0) return null;
1679
+ return {
1680
+ key: `sparkline|${width}x${height}|${this.props.seriesId}`,
1681
+ x: Math.round(this.abs.x),
1682
+ y: Math.round(this.abs.y),
1683
+ width,
1684
+ height,
1685
+ format: 'argb32',
1686
+ tint: null,
1687
+ };
1688
+ }
1689
+
1690
+ paintCached(ctx, box) {
1691
+ this.drawSeries(ctx, box.x, box.y, box.width, box.height);
1692
+ }
1693
+ ```
1694
+
1695
+ `format: 'a8'` stores coverage rather than colour and paints through `tint`,
1696
+ so one rendered copy serves every colour the drawing is ever asked for — the
1697
+ right choice for a monochrome drawing, and it keeps the colour out of the key.
1698
+
1699
+ **The key is the entire correctness surface.** It must name every input
1700
+ `paintCached` reads, and it should be derived from the same values
1701
+ `applyProps` compares so the two cannot drift. A key that misses something
1702
+ shows stale pixels, which is the hardest kind of bug to see — so develop with
1703
+ `REACT_X11_PAINT_CACHE=verify`, which re-renders every hit and complains
1704
+ loudly when the drawing changed while the key did not. Return `null` for
1705
+ anything animated, hovered, focused or blinking.
1706
+
1707
+ Two footguns worth naming. `paintCached` draws in **surface-local**
1708
+ coordinates: reaching for `this.abs.x` inside it is the first thing to check
1709
+ when a cached element paints in the wrong place. And the cache is per X
1710
+ connection and keyed only on your string, so make the key specific enough
1711
+ that two different drawings cannot collide — prefix it with your element
1712
+ name, as above.
1713
+
1714
+ `globalAlpha`, the clip and any ancestor translation are applied when the
1715
+ cached copy is composited, so an ancestor animating opacity is a cache hit
1716
+ rather than a reason to opt out.
1717
+
1718
+ ### Parts of your drawing on layers of their own
1719
+
1720
+ An element that draws a scene has animations inside it — a document's CSS
1721
+ animation on one element, a cursor's blink, an edge's dash, a tooltip's
1722
+ fade — and each is a frame of JavaScript: restyle, claim, repaint. On macOS
1723
+ the render server can run such an animation itself, with no frame at all, if
1724
+ what moves is on a layer of its own. Layer promotion does that for a plain
1725
+ `<box>` ([macos.md](macos.md#layer-promotion-the-animated-few-on-their-own-layers-above-the-surface-presenter));
1726
+ an element whose content is its own `paint` answers **sprites**: the parts
1727
+ of its drawing it would like lifted, each with what the layer shows and what
1728
+ runs on it.
1729
+
1730
+ ```js
1731
+ sprites() {
1732
+ const card = this.cardRect(); // device pixels, window coordinates
1733
+ return [
1734
+ {
1735
+ key: 'card',
1736
+ rect: card,
1737
+ version: this.cardVersion,
1738
+ paint: (ctx) => this.drawCard(ctx, card), // where it is, unfaded
1739
+ opacity: 1, // what it rests at
1740
+ animations: [
1741
+ {
1742
+ id: `fade-${this.fadeRun}`,
1743
+ property: 'opacity',
1744
+ values: [0, 1],
1745
+ timings: [[0.25, 0.1, 0.25, 1]],
1746
+ duration: 300, // ms
1747
+ delay: this.fadeStart - performance.now(), // ms; negative is begun
1748
+ },
1749
+ ],
1750
+ },
1751
+ ];
1752
+ }
1753
+
1754
+ spritesLifted(keys) {
1755
+ this.lifted = keys; // paint() leaves these out now
1756
+ }
1757
+
1758
+ spriteAnimationEnded(key, id, finished) {
1759
+ this.fadeDone = true;
1760
+ this.spritesChanged(); // the frame that asks again
1761
+ }
1762
+ ```
1763
+
1764
+ | method | |
1765
+ | ----------------------------------------- | --------------------------------------------------------------------------- |
1766
+ | `sprites()` | the parts on offer, or `null`; asked every frame, after layout |
1767
+ | `spritesLifted(keys)` | which of them are on layers now — before the frame paints |
1768
+ | `spriteAnimationEnded(key, id, finished)` | the render server is done with one: it ran out, or its layer went |
1769
+ | `spritesChanged()` | ask for that frame when the parts changed and nothing the element draws did |
1770
+
1771
+ A part is its `key`, its `rect` (and a `reach`, if it draws past it, and a
1772
+ `clip`, if you cut it to one, with a `clipRadius` where the clip's corners
1773
+ are round), a `paint` that draws it **where it is in the
1774
+ window, at opacity 1 and untransformed** — the layer carries both — and a
1775
+ `version` whose change paints it again. Its `opacity` and its `transform`, CSS's `matrix(a, b, c,
1776
+ d, e, f)` as an array with `e` and `f` in device pixels, about an `origin`
1777
+ that defaults to the rect's centre, are what the layer shows at rest. A
1778
+ part turned out of the plane hands over `matrix3d()`'s sixteen numbers
1779
+ instead, column by column, its translation and the distance its perspective
1780
+ divides by in device pixels: Core Animation draws the layer through the
1781
+ whole matrix, perspective and all, on the GPU. The presenter lifts such a
1782
+ part only where none of its corners is behind the viewer, at rest or in
1783
+ any keyframe, since past that plane its picture has no bounds, and asks
1784
+ about it where its corners are seen rather than where it was laid out. Its
1785
+ `animations` are keyframes for one of those two properties each: `values`,
1786
+ `keyTimes`, a `timings` curve per segment, a `duration`, a `repeat`
1787
+ (`Infinity` loops), `autoreverse`, and `hold` to stay on the last value. A
1788
+ CSS animation goes over as it plays: an opacity's keyframes with their
1789
+ curves, and a transform **sampled** into matrices from the element's own
1790
+ interpolation, which is what makes a full turn turn and a mixed function
1791
+ list interpolate as CSS says — Core Animation's own interpolation would do
1792
+ neither.
1793
+
1794
+ The rules the presenter decides by, every frame, are promotion's, asked of
1795
+ the element at **everywhere the part can be** — its reach through every
1796
+ transform it is animated through:
1797
+
1798
+ - **Nothing painted after the element reaches into what shows of it** — no
1799
+ later sibling, no ancestor's border, ring or scrollbar. What shows is
1800
+ what is inside its `clip` and inside every clipping ancestor with square
1801
+ corners: the layer goes in a box that masks it to them, so a carousel's
1802
+ slide under `overflow: hidden`, or a part scrolled half out of its pane,
1803
+ is cut there as the bitmap is. A rounded ancestor has to hold all of it,
1804
+ since one box cannot take its corners. A `clip` of your own may have
1805
+ round corners — a card with a radius, cutting what it holds — and the
1806
+ box takes them; an ancestor with square corners that cuts it again puts
1807
+ that box in a box of its own. A part none of which shows — scrolled out
1808
+ of its pane, or outside its own clip — stays on its layer, cut to show
1809
+ nothing, so that its animation runs where it would and none of yours is
1810
+ spent on it; past a budget of raster for such parts (32 MB a window), it
1811
+ is yours to draw.
1812
+ - **Neither the element nor any ancestor fades**: their group on the bitmap
1813
+ would leave the layer out of it.
1814
+ - **Your inside is yours.** Offer only a part that nothing you draw after it
1815
+ overlaps — but for another part you offer after it, since that one's layer
1816
+ stands above: list your parts in the order you paint them, and they stack
1817
+ in it. The presenter cannot see inside your `paint`, so a part it turns
1818
+ down is yours to draw, in the bitmap under every layer, and every part
1819
+ listed before it that it overlaps is turned down with it, in that frame;
1820
+ one it does not reach keeps its layer.
1821
+
1822
+ A part can be inside another of yours — a spinner in a card that fades —
1823
+ by naming it as its `parent`, earlier in the list. Its layer goes in the
1824
+ parent's layer, so it fades, turns and is cut with it, and it is asked
1825
+ about nothing more than staying inside the parent's raster everywhere it
1826
+ can be: the parent was asked about all of that. Its `rect`, `origin` and
1827
+ `clip` are where it is in the window with its parent untransformed. The
1828
+ parent's `paint` is handed the keys of the parts lifted inside it, and
1829
+ leaves them out; the presenter paints it again whenever they change, in
1830
+ the frame they do. A part whose parent is not lifted, or that reaches out
1831
+ of it, is yours to draw — in the parent's raster, where the parent is
1832
+ lifted.
1833
+
1834
+ A lifted part is a hole in your drawing from the frame `spritesLifted` says
1835
+ so, and a part the set no longer holds is yours to draw again in that same
1836
+ frame — the bitmap under it is claimed as it comes back. Declining is always
1837
+ safe, and it is what happens everywhere but on macOS: X11, Wayland, the
1838
+ layer presenter and a scene that refuses never ask, and your `paint` draws
1839
+ everything as it always did, so the same element runs on every backend and
1840
+ the animation runs on your own clock there.
1841
+
1842
+ #### A part that shows a source
1843
+
1844
+ A part may show something the presenter does not paint at all: its
1845
+ `contents`, in place of `paint` and `version`. Today that is a
1846
+ `VideoFrames` sink ([elements.md](elements.md#video)) — a camera feed in a
1847
+ chart's corner, a preview inside a document — whose every `push` reaches the
1848
+ part's layer by itself, with no frame of the window's and no `paint`:
1849
+
1850
+ ```js
1851
+ sprites() {
1852
+ const frames = this.props.frames;
1853
+ if (!frames?.frame) return null; // nothing to show yet
1854
+ const rect = this.previewRect(); // device pixels, on whole pixels
1855
+ return [{ key: 'preview', rect, contents: frames }];
1856
+ }
1857
+
1858
+ paint(ctx) {
1859
+ super.paint(ctx);
1860
+ if (!this.lifted.has('preview')) this.drawPreview(ctx); // the newest frame
1861
+ }
1862
+ ```
1863
+
1864
+ The layer is exactly `rect` — the picture scaled into it — so put its edges
1865
+ on whole pixels, or the hole you leave and the layer that fills it meet with
1866
+ a seam. Everything else is a part's: the rules above, a `clip` if you cut it,
1867
+ `spritesChanged()` when the part comes or goes. A presenter that cannot show
1868
+ the source — any bridge without video surfaces, and every backend but macOS —
1869
+ declines the part, and drawing the newest frame yourself is the same code
1870
+ that runs in every frame something is painted over it. `<video>` is built
1871
+ exactly this way (src/nodes/video.js).
1872
+
1873
+ Three things to know about ids and time. An animation is attached **once per
1874
+ `id`**: one that changed is a new id, and one the render server finished is
1875
+ not started again while you still list it. Its `delay` counts from the frame
1876
+ that asks, so it is a fresh number every frame and read only on the first.
1877
+ And a part is painted once unless its `version` changes — a move by whole
1878
+ pixels moves the layer, but a move to another fraction of a pixel, or a new
1879
+ size, paints it again.
1880
+
1881
+ Sprites need `@windowkit/appkit` 0.19 or later, which takes a transform as
1882
+ a matrix or a matrix3d and a negative delay as one begun in the past: on an
1883
+ older bridge nothing is asked, and the element draws everything. A bridge
1884
+ that lists `matrix` and not `matrix3d` among its `transformForms()` lifts
1885
+ the parts in the plane and leaves those out of it to their elements.
1886
+
1887
+ #### A part that is its shadows
1888
+
1889
+ A part may be **shadows** alone: CSS's outer `box-shadow`, which the
1890
+ render server draws on the GPU where your `paint` would blur a glow a pixel
1891
+ at a time. It has `shadows` in place of `paint`, its `rect` is the box that
1892
+ casts them — none of them falls inside it, with `rectRadius` corners, as an
1893
+ outer shadow is never drawn under its box — and each shadow is the shape it
1894
+ is the shadow of, with its corners' `radius`, an offset `x` and `y`, CSS's
1895
+ `blur` radius and a `color`, `[r, g, b, a]` in straight sRGB from 0 to 1.
1896
+ The first is on top, as CSS lists them; a spread is yours to fold into
1897
+ the shape.
1898
+
1899
+ ```js
1900
+ sprites() {
1901
+ const card = this.cardRect(); // device pixels, window coordinates
1902
+ const s = this.scale;
1903
+ return [
1904
+ {
1905
+ key: 'card-glow',
1906
+ rect: card,
1907
+ rectRadius: 8 * s,
1908
+ shadows: [
1909
+ { rect: card, radius: 8 * s, blur: 200 * s, color: [0.09, 0.63, 0.79, 0.25] },
1910
+ { rect: card, radius: 8 * s, y: 3 * s, blur: 2 * s, color: [0.38, 0.45, 0.47, 0.05] },
1911
+ ],
1912
+ animations: [
1913
+ {
1914
+ id: `glow-${this.hoverRun}`,
1915
+ property: 'shadowBlur',
1916
+ shadow: 0, // which of them
1917
+ values: [0, 200 * s],
1918
+ duration: 250,
1919
+ },
1920
+ ],
1921
+ },
1922
+ ];
1923
+ }
1924
+ ```
1925
+
1926
+ Their `animations` are of `'shadowBlur'`, `'shadowColor'` and
1927
+ `'shadowOffset'`, with `shadow` saying which: blurs and `[x, y]` offsets in
1928
+ device pixels, colours as above, and an `opacity` and a `transform` on the
1929
+ whole part as any part has. A transition between two lists of shadows is
1930
+ yours to pad, as CSS pads it, and to sample where Core Animation's straight
1931
+ interpolation of a colour would not be CSS's. Where they can reach is
1932
+ worked out from the shadows through every keyframe, and that is everywhere
1933
+ the part is asked about: a part that is its shadows stands over what is
1934
+ under its glow, so nothing painted after your element may reach into it.
1935
+ Leave the box itself in your bitmap and draw everything but its shadows
1936
+ there while the part is lifted. A part that is its shadows is never inside
1937
+ another's.
1938
+
1939
+ ### Elements that own a real X window
1940
+
1941
+ `drawn: false` is for a node backed by its own child X window rather than
1942
+ painted into its parent — a GL surface, an XEMBED socket, a video overlay.
1943
+ There are two worked examples, and they are worth reading in this order.
1944
+
1945
+ `GlAreaNode` (`src/glnodes.js`) is the simple one: it holds no paint code,
1946
+ and the owning `WindowNode` realizes it in the commit phase
1947
+ (`_realizeChildWindows`) so the child window names its real parent from the
1948
+ start. The ordering constraint — no X calls in the render phase, because the
1949
+ render phase is discardable — is the part that is easy to get wrong.
1950
+
1951
+ Input is the other thing a child window decides without being asked. X
1952
+ delivers a device event to the first window up the hierarchy that selected
1953
+ it, so a window that selects nothing hands the pointer over it to the window
1954
+ the tree lives in — which is how a `<glarea>`'s presses reach the tree — and
1955
+ one that selects `ButtonPress` keeps every press for itself. Select only
1956
+ what the element needs from the server, and remember that ntk selects
1957
+ whatever a window is listened to for. The panes a `<glarea>`'s children are
1958
+ drawn on (`src/gloverlay.js`) select nothing for the same reason. The owning
1959
+ window's hit test knows about `<glarea>` surfaces and their children and
1960
+ asks them before the tree (`GlAreaNode.hitSurface`); it does not know about
1961
+ a registered element's window, so a point over one lands on whatever the
1962
+ tree has behind it.
1963
+
1964
+ `ForeignNode` (`src/foreignnodes.js`) is the same shape with the stakes
1965
+ raised, and it is the one to read if your element touches a resource you did
1966
+ not create. Three things it has to do that a GL surface does not:
1967
+
1968
+ - **Nothing in the render phase, for a sharper reason.** A `CreateWindow`
1969
+ from a discarded render leaks a server resource; a `ReparentWindow` from
1970
+ one has moved another application's window for real.
1971
+ - **Teardown is synchronous.** `WindowNode.destroySubtree` destroys its own
1972
+ X window in the same turn your node's does, and `DestroyWindow` takes every
1973
+ inferior with it — so a release that waits for a round trip releases a
1974
+ window that no longer exists. Compute what you would have asked for; the
1975
+ requests are ordered on the connection, which is what makes "hand it back,
1976
+ _then_ destroy the container" a guarantee.
1977
+ - **Give it back, do not destroy it.** The element's own test file asserts
1978
+ that first, because it is the failure this shape is most able to cause.
1979
+
1980
+ ## A layout algorithm of your own
1981
+
1982
+ `layout` on a box ([styling.md](styling.md#custom-layouts)) names an
1983
+ algorithm, and `registerLayout` teaches react-x11 one. A radial menu's:
1984
+
1985
+ ```js
1986
+ import { registerLayout } from 'react-x11/host';
1987
+
1988
+ registerLayout('radial', {
1989
+ options: { radius: { type: 'length', default: 80 } },
1990
+ layout(children, c, { radius }) {
1991
+ const sizes = children.map((child) => child.measure());
1992
+ const reach = Math.max(0, ...sizes.map((s) => Math.max(s.width, s.height)));
1993
+ const width = c.widthMode === 'exactly' ? c.width : 2 * radius + reach;
1994
+ const height = c.heightMode === 'exactly' ? c.height : 2 * radius + reach;
1995
+ const step = (2 * Math.PI) / Math.max(1, children.length);
1996
+ return {
1997
+ width,
1998
+ height,
1999
+ children: sizes.map((s, i) => ({
2000
+ x: width / 2 + radius * Math.cos(i * step) - s.width / 2,
2001
+ y: height / 2 + radius * Math.sin(i * step) - s.height / 2,
2002
+ })),
2003
+ };
2004
+ },
2005
+ });
2006
+ ```
2007
+
2008
+ ```jsx
2009
+ <box style={{ layout: { name: 'radial', radius: 64 } }}>
2010
+ {tools.map((tool) => (
2011
+ <ToolButton key={tool.id} tool={tool} />
2012
+ ))}
2013
+ </box>
2014
+ ```
2015
+
2016
+ **One function, two questions.** `layout(children, constraints, options,
2017
+ info)` is asked how big the box's content is for `constraints` —
2018
+ `{ width, height, widthMode, heightMode }`, the vocabulary `measureContent`
2019
+ speaks, with `Infinity` for no bound — several times a pass, at sizes
2020
+ nothing is drawn at. When both modes are `'exactly'` the box has its size,
2021
+ and the answer places too: `children`, one rect per child, `{ x, y }` from
2022
+ the content box's corner, with a `width` and a `height` to lay the child out
2023
+ at — either one left out is the child's own. So it is a question about a
2024
+ hypothetical, under the rules `measureContent` has
2025
+ ([above](#what-it-must-not-do)): no reading `abs`, no state, finite numbers.
2026
+
2027
+ **What it gets of a child** is a `LayoutChild`, never a node:
2028
+
2029
+ | member | |
2030
+ | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
2031
+ | `measure(constraints?)` | the child's size under the constraints. A number with no mode is `'exactly'`, an axis left out `'unconstrained'`, `'at-most'` fit-content |
2032
+ | `intrinsicSizes()` | `{ minContentWidth, maxContentWidth }`: the narrowest it can be drawn at — its content floor — and the width it takes with no bound |
2033
+ | `options` | its `layoutItem`, against your `childOptions` |
2034
+ | `style` | its own resolved style, read-only, in device pixels — what `grid` places a child by (`gridColumn`) and aligns it with (`alignSelf`) |
2035
+ | `index` | its place in the list |
2036
+
2037
+ Every size is the child's **margin box** — a margin is part of the room a
2038
+ child takes — so an algorithm that lines children up is right about margins
2039
+ without knowing they exist; and every size is in device pixels, like
2040
+ `measureContent`'s.
2041
+
2042
+ **Options are declared, and typed.** `options` and `childOptions` map each
2043
+ name to `{ type, default }`: `'length'`, `'number'`, `'integer'`,
2044
+ `'boolean'`, `'string'`, `'any'`, or an array of the values it may take. A
2045
+ `length` is written in logical pixels in the style and arrives in device
2046
+ pixels, so an algorithm never multiplies by the scale. A value of the wrong
2047
+ type, or an option you did not declare, is reported naming it, and takes
2048
+ the default. What the box's own style already says — `gap`,
2049
+ `justifyContent`, `alignItems` — is `info.style`: read it rather than
2050
+ declaring options that mean the same thing. `info.scale` is the box's device
2051
+ pixels per logical pixel, for a length the schema cannot see: one inside a
2052
+ string, like a grid's track list. `info.report(message, consequence)` says
2053
+ that something in a style is wrong but can be laid out around — a grid area
2054
+ nobody named — once, the way a bad style value is said; a throw is for what
2055
+ cannot be laid out at all.
2056
+
2057
+ **What core does, so the algorithm does not have to.** It lays each child
2058
+ out at its rect, mirrors the slots when the box reads right to left and
2059
+ rounds them to whole pixels, places absolutely positioned children by their
2060
+ insets, measures the children's content floors, carries a child's change
2061
+ into the next pass, and — when the algorithm throws, or answers something
2062
+ that is not a size — reports it and lays the box out as flexbox, in the same
2063
+ frame.
2064
+
2065
+ Register before you render, as for an element. A name registered twice with
2066
+ a different function throws unless `override: true`; under hot reload a
2067
+ module re-registering its own replaces it, and a mounted box keeps the
2068
+ definition it started with until its style names a layout again. The
2069
+ built-in `masonry` and `equal-row` are worked examples (`src/layouts.js`),
2070
+ and so is `grid` (`src/grid.js`), which takes no options: it reads the box's
2071
+ tracks from `info.style` and each child's place from the child's `style`.
2072
+
2073
+ In TypeScript, augment `CustomLayouts` with your layout's options and
2074
+ `layout: { name: 'radial', radius: 40 }` type-checks, typos included;
2075
+ `CustomLayoutItem` is what a child may say:
2076
+
2077
+ ```ts
2078
+ declare module 'react-x11/host' {
2079
+ interface CustomLayouts {
2080
+ radial: { radius?: number };
2081
+ }
2082
+ }
2083
+ ```
2084
+
2085
+ ## A position of your own
2086
+
2087
+ `registerPosition` adds a positioning scheme
2088
+ ([styling.md](styling.md#custom-positions)): the box is laid out in flow, as
2089
+ `relative` is, and after every layout pass `place(node, context)` says how
2090
+ far to move it from there. A drop's flight home — from where the pointer let
2091
+ go to where layout put the item:
2092
+
2093
+ ```js
2094
+ import { registerPosition } from 'react-x11/host';
2095
+
2096
+ registerPosition('landing', {
2097
+ options: {
2098
+ from: { type: 'any' }, // { x, y }, window coordinates
2099
+ started: { type: 'number' },
2100
+ ms: { type: 'number', default: 200 },
2101
+ },
2102
+ place(node, { laidOut, now, options: { from, started, ms } }) {
2103
+ if (!from) return null;
2104
+ const t = Math.min(1, (now - started) / ms);
2105
+ const left = (1 - t) * (1 - t); // easing out
2106
+ return {
2107
+ x: (from.x - laidOut.x) * left,
2108
+ y: (from.y - laidOut.y) * left,
2109
+ again: t < 1,
2110
+ };
2111
+ },
2112
+ });
2113
+ ```
2114
+
2115
+ `place` returns `{ x, y }` — how far to move the node, in device pixels, which
2116
+ are rounded to whole ones for you — or null to leave it where layout put it;
2117
+ `again: true` asks for another frame, which runs the placement pass with
2118
+ nothing to lay out. It resizes nothing and sets no state: it runs on every
2119
+ layout pass, a scroll's included, which is what lets what it moves land in
2120
+ the frame that scrolled.
2121
+
2122
+ What it is handed, in window coordinates and device pixels, the space `abs`
2123
+ is in:
2124
+
2125
+ | field | |
2126
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------ |
2127
+ | `laidOut` | `{ x, y, width, height }`: where layout put the node, before any placement moved it |
2128
+ | `pane` | the nearest scroll pane: its `scrollport` — inside its border, over its padding — and `scrollX`/`scrollY`; or null |
2129
+ | `container` | the box it is contained by: its parent's content box, or the whole scrolled content for a direct child of a pane |
2130
+ | `margin` | its margins |
2131
+ | `direction`/`scale` | the node's |
2132
+ | `now` | the frame clock, in ms |
2133
+ | `options` | its options, resolved as a layout's are |
2134
+
2135
+ `node` is the node itself, for its `style`: the insets are the scheme's to
2136
+ read — sticky reads them as thresholds — and yoga never sees them as
2137
+ offsets.
2138
+
2139
+ **What core does.** It re-derives where layout put the node on every pass
2140
+ (nothing may remember an offset: the scroll fast path moves a node with its
2141
+ pane, offset and all), claims where it was and where it went as damage —
2142
+ into the scroll blit's ledger, so a pane of placed nodes keeps the blit —
2143
+ lifts it over its siblings of the same `zIndex` for paint and for hit
2144
+ testing, and when `place` throws, reports it once and leaves the node where
2145
+ layout put it until its style names another position. `position: 'sticky'`
2146
+ is written against exactly this context (`placeSticky` in `src/layouts.js`).
2147
+
2148
+ In TypeScript, augment `CustomPositions` the way `CustomLayouts` is.
2149
+
2150
+ ## The subpath exports
2151
+
2152
+ | subpath | |
2153
+ | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
2154
+ | `react-x11/host` | `registerElement`, `registerLayout`, `registerPosition` and their `unregister…`/`registered…` pairs, `hostTypes`, `knownElements`, `drawnKinds` |
2155
+ | `react-x11/node` | `Node`, the built-in node classes, `Scrollable`, `intrinsicSize` |
2156
+ | `react-x11/style` | `createStyles`, `flattenStyle`, `isStyleProp`, `resolveTokens`, the rest of the vocabulary |
2157
+ | `react-x11/yoga` | the layout engine — `Yoga`, `loadLayout`, `layoutLoaded`. Rarely needed; see below |
2158
+ | `react-x11/ntk` | ntk itself, re-exported — `Path2D`, `Image`, `Pixmap`, the font sources, `createClient` — `Surface`, on whichever backend the app is, and `decodeImageBytes` |
2159
+ | `react-x11/keysyms` | the `XK_*` constants, `keysymOf`, `charOf`, `MOD`, `ctrlChordLetter` |
2160
+
2161
+ **Reach ntk through `react-x11/ntk`, not a second dependency.** Two copies
2162
+ of ntk in one process means two font caches and two glyph atlases, and a
2163
+ node built against one cannot be painted by the other — a failure that
2164
+ looks like a drawing bug rather than a dependency bug. It is also where a
2165
+ drawing-adjacent name gets its backend-neutral answer: `Surface` from here
2166
+ is a pixmap on X11, a CG bitmap on the Cocoa backend, a GL render target on
2167
+ Wayland and a Direct2D bitmap on win32, where `Surface` from `ntk` is the
2168
+ pixmap only. And it is where an element that draws images of its own
2169
+ decodes them: `decodeImageBytes` is the ladder `<image>` reads
2170
+ bytes with — WebP everywhere, and under Bun every format Bun reads, off the
2171
+ JavaScript thread — where ntk's `decodeImage` is PNG and JPEG, synchronously
2172
+ ([formats](elements.md#formats-and-what-decodes-them)).
2173
+
2174
+ **An element does not need the layout engine, and that is deliberate.**
2175
+ `measureContent` is handed its constraints in words (`'exactly'`,
2176
+ `'at-most'`, `'unconstrained'`) rather than yoga's integers, so an element
2177
+ never links against it and yoga's ABI never becomes part of this seam. If you
2178
+ are writing an element and reaching for `react-x11/yoga`, the answer is
2179
+ almost certainly `measureContent`.
2180
+
2181
+ `react-x11/yoga` is for the case that is genuinely different. A layout that
2182
+ arranges a box's children is `registerLayout`
2183
+ ([above](#a-layout-algorithm-of-your-own)) and never needs it; what does is
2184
+ an element laying out content it draws itself that wants to delegate part
2185
+ of that to flexbox. `@react-x11/components`'s `<Html>` is the worked example
2186
+ — its `display: flex` builds a small yoga tree, asks it, and reads the
2187
+ answer back rather than re-deriving flexbox, which is long, subtle, and
2188
+ silently wrong when it is wrong.
2189
+
2190
+ Such a package must use **this** engine rather than its own `yoga-layout`
2191
+ dependency, for the same reason it must reach ntk through `react-x11/ntk`:
2192
+ two instances are two WebAssembly modules, and a node created by one cannot
2193
+ be inserted into a tree owned by the other. That failure surfaces as a crash
2194
+ inside the engine, naming nothing you wrote.
2195
+
2196
+ ```ts
2197
+ import { Yoga, loadLayout } from 'react-x11/yoga';
2198
+
2199
+ await loadLayout(); // createRoot() has already done this inside an app
2200
+ const root = Yoga.Node.create();
2201
+ root.setFlexDirection(Yoga.FLEX_DIRECTION_ROW);
2202
+ ```
2203
+
2204
+ The enum constants are readable from the first tick; `Node` and `Config`
2205
+ throw until the assembly is loaded, which `createRoot()` awaits before it
2206
+ builds anything (docs/packaging.md explains why it is loaded rather than
2207
+ imported).
2208
+
2209
+ ## Typing
2210
+
2211
+ The element also has to be declared to JSX, which is module augmentation —
2212
+ the same mechanism [typescript.md](typescript.md) describes:
2213
+
2214
+ ```ts
2215
+ import type { Style } from 'react-x11/style';
2216
+
2217
+ declare module 'react-x11/jsx-runtime' {
2218
+ namespace JSX {
2219
+ interface IntrinsicElements {
2220
+ sparkline: {
2221
+ data: number[];
2222
+ color?: string;
2223
+ style?: Style;
2224
+ ref?: React.Ref<unknown>;
2225
+ };
2226
+ }
2227
+ }
2228
+ }
2229
+ ```
2230
+
2231
+ `test/types/extend.tsx` in this repo compiles exactly that, so the story
2232
+ stays true as the declarations move.
2233
+
2234
+ The drawing side is declared too. `Context2D` from `react-x11/node` is the
2235
+ canvas-shaped subset the backends implement — ntk's context over
2236
+ XRender, and the one `src/backend/context2d.js` wraps over CoreGraphics on
2237
+ Cocoa and Direct2D on win32 — so `paintContent(ctx: Context2D)`
2238
+ type-checks every call an element makes, and a member not every backend
2239
+ has is optional there (`globalCompositeOperation`, which a native context
2240
+ takes only for the modes its bridge draws — assign it and read it back) or
2241
+ absent. `paintCachePlan`, `paintCached`, `opaqueRect` and the rest of the
2242
+ contract above are on the class, with `PaintCachePlan` for the plan.