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.
- package/README.md +87 -54
- package/docs/README.md +410 -0
- package/docs/accessibility.md +628 -0
- package/docs/appearance.md +416 -0
- package/docs/architecture/accessibility.md +470 -0
- package/docs/architecture/animation.md +707 -0
- package/docs/architecture/container-queries.md +525 -0
- package/docs/architecture/custom-layout.md +334 -0
- package/docs/architecture/drag-and-drop.md +941 -0
- package/docs/architecture/element-layer-contents.md +622 -0
- package/docs/architecture/frame-pacing.md +255 -0
- package/docs/architecture/grid-layout.md +358 -0
- package/docs/architecture/protocol-efficiency.md +357 -0
- package/docs/architecture/video.md +1149 -0
- package/docs/architecture/windowed-regions.md +854 -0
- package/docs/click-to-component.md +125 -0
- package/docs/clipboard.md +281 -0
- package/docs/components.md +1749 -0
- package/docs/dbus.md +277 -0
- package/docs/debugging.md +464 -0
- package/docs/desktop-calendar.md +284 -0
- package/docs/desktop.md +648 -0
- package/docs/devtools.md +250 -0
- package/docs/drag-and-drop.md +663 -0
- package/docs/ecosystem/animation.md +394 -0
- package/docs/ecosystem/data-fetching.md +201 -0
- package/docs/ecosystem/dev-tooling.md +436 -0
- package/docs/ecosystem/forms.md +313 -0
- package/docs/ecosystem/headless.md +219 -0
- package/docs/ecosystem/i18n.md +89 -0
- package/docs/ecosystem/icons.md +410 -0
- package/docs/ecosystem/layout.md +216 -0
- package/docs/ecosystem/routing.md +146 -0
- package/docs/ecosystem/state.md +361 -0
- package/docs/ecosystem/testing.md +500 -0
- package/docs/ecosystem/theming.md +540 -0
- package/docs/ecosystem.md +473 -0
- package/docs/elements.md +2762 -0
- package/docs/embedding.md +288 -0
- package/docs/events.md +1025 -0
- package/docs/extending.md +2242 -0
- package/docs/eyedropper.md +223 -0
- package/docs/filedialog.md +258 -0
- package/docs/frame.md +580 -0
- package/docs/gl.md +170 -0
- package/docs/globalmenu.md +278 -0
- package/docs/glx.md +137 -0
- package/docs/macos.md +3295 -0
- package/docs/notifications.md +164 -0
- package/docs/packaging.md +611 -0
- package/docs/permissions.md +148 -0
- package/docs/react-features.md +430 -0
- package/docs/remote.md +223 -0
- package/docs/scale.md +339 -0
- package/docs/security.md +132 -0
- package/docs/styling.md +1928 -0
- package/docs/system.md +599 -0
- package/docs/testing.md +514 -0
- package/docs/typescript.md +117 -0
- package/docs/uri-schemes.md +380 -0
- package/docs/wayland-backend.md +591 -0
- package/docs/wayland.md +545 -0
- package/docs/windows-embedding.md +271 -0
- package/docs/windows-gl.md +221 -0
- package/docs/windows-integrations.md +233 -0
- package/docs/windows.md +1404 -0
- package/package.json +10 -3
- package/src/bus.js +39 -26
- package/src/cli/create.d.ts +43 -0
- package/src/cli/create.js +305 -0
- package/src/cli/env.js +484 -0
- package/src/cli/index.js +29 -2
- package/src/cli/templates/AGENTS.template.md +195 -0
- package/src/cli/templates/DESIGN.template.md +269 -0
- package/src/desktopintegration.js +2 -1
- package/src/desktopsettings.js +193 -24
- package/src/filedialog.js +3 -3
- package/src/keyboardstate.js +28 -0
- package/src/node.d.ts +10 -10
- package/src/portal.js +31 -0
- package/src/screencolor.js +5 -10
- package/src/types/system.d.ts +8 -6
- package/src/wayland/app.js +59 -0
- package/src/wayland/clipboard.js +14 -7
- package/src/wayland/connection.js +3 -0
- package/src/wayland/context2d.js +129 -10
- package/src/wayland/protocols/index.json +6 -0
- package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
- package/src/wayland/seat.js +28 -1
- package/src/wayland/swapchain.js +23 -0
- 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.
|