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