react-x11 2.51.0 → 2.52.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -54
- package/docs/README.md +410 -0
- package/docs/accessibility.md +628 -0
- package/docs/appearance.md +416 -0
- package/docs/architecture/accessibility.md +470 -0
- package/docs/architecture/animation.md +707 -0
- package/docs/architecture/container-queries.md +525 -0
- package/docs/architecture/custom-layout.md +334 -0
- package/docs/architecture/drag-and-drop.md +941 -0
- package/docs/architecture/element-layer-contents.md +622 -0
- package/docs/architecture/frame-pacing.md +255 -0
- package/docs/architecture/grid-layout.md +358 -0
- package/docs/architecture/protocol-efficiency.md +357 -0
- package/docs/architecture/video.md +1149 -0
- package/docs/architecture/windowed-regions.md +854 -0
- package/docs/click-to-component.md +125 -0
- package/docs/clipboard.md +281 -0
- package/docs/components.md +1749 -0
- package/docs/dbus.md +277 -0
- package/docs/debugging.md +464 -0
- package/docs/desktop-calendar.md +284 -0
- package/docs/desktop.md +648 -0
- package/docs/devtools.md +250 -0
- package/docs/drag-and-drop.md +663 -0
- package/docs/ecosystem/animation.md +394 -0
- package/docs/ecosystem/data-fetching.md +201 -0
- package/docs/ecosystem/dev-tooling.md +436 -0
- package/docs/ecosystem/forms.md +313 -0
- package/docs/ecosystem/headless.md +219 -0
- package/docs/ecosystem/i18n.md +89 -0
- package/docs/ecosystem/icons.md +410 -0
- package/docs/ecosystem/layout.md +216 -0
- package/docs/ecosystem/routing.md +146 -0
- package/docs/ecosystem/state.md +361 -0
- package/docs/ecosystem/testing.md +500 -0
- package/docs/ecosystem/theming.md +540 -0
- package/docs/ecosystem.md +473 -0
- package/docs/elements.md +2762 -0
- package/docs/embedding.md +288 -0
- package/docs/events.md +1025 -0
- package/docs/extending.md +2242 -0
- package/docs/eyedropper.md +223 -0
- package/docs/filedialog.md +258 -0
- package/docs/frame.md +580 -0
- package/docs/gl.md +170 -0
- package/docs/globalmenu.md +278 -0
- package/docs/glx.md +137 -0
- package/docs/macos.md +3295 -0
- package/docs/notifications.md +164 -0
- package/docs/packaging.md +611 -0
- package/docs/permissions.md +148 -0
- package/docs/react-features.md +430 -0
- package/docs/remote.md +223 -0
- package/docs/scale.md +339 -0
- package/docs/security.md +132 -0
- package/docs/styling.md +1928 -0
- package/docs/system.md +599 -0
- package/docs/testing.md +514 -0
- package/docs/typescript.md +117 -0
- package/docs/uri-schemes.md +380 -0
- package/docs/wayland-backend.md +591 -0
- package/docs/wayland.md +545 -0
- package/docs/windows-embedding.md +271 -0
- package/docs/windows-gl.md +221 -0
- package/docs/windows-integrations.md +233 -0
- package/docs/windows.md +1404 -0
- package/package.json +10 -3
- package/src/bus.js +39 -26
- package/src/cli/create.d.ts +43 -0
- package/src/cli/create.js +305 -0
- package/src/cli/env.js +484 -0
- package/src/cli/index.js +29 -2
- package/src/cli/templates/AGENTS.template.md +195 -0
- package/src/cli/templates/DESIGN.template.md +269 -0
- package/src/desktopintegration.js +2 -1
- package/src/desktopsettings.js +193 -24
- package/src/filedialog.js +3 -3
- package/src/keyboardstate.js +28 -0
- package/src/node.d.ts +10 -10
- package/src/portal.js +31 -0
- package/src/screencolor.js +5 -10
- package/src/types/system.d.ts +8 -6
- package/src/wayland/app.js +59 -0
- package/src/wayland/clipboard.js +14 -7
- package/src/wayland/connection.js +3 -0
- package/src/wayland/context2d.js +129 -10
- package/src/wayland/protocols/index.json +6 -0
- package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
- package/src/wayland/seat.js +28 -1
- package/src/wayland/swapchain.js +23 -0
- package/src/wayland/xkb.js +137 -0
package/docs/elements.md
ADDED
|
@@ -0,0 +1,2762 @@
|
|
|
1
|
+
# Elements
|
|
2
|
+
|
|
3
|
+
Only `<window>`, `<popup>`, `<glarea>` and `<foreign>` are backed by real
|
|
4
|
+
windows of the display system — on X11, X windows created top-down in
|
|
5
|
+
React's commit phase so every `CreateWindow` names its actual parent — and,
|
|
6
|
+
for `<foreign>`, so that no `ReparentWindow` is ever issued from a render
|
|
7
|
+
React might discard.
|
|
8
|
+
Everything else is a retained lightweight node — one
|
|
9
|
+
[yoga-layout](https://www.yogalayout.dev/) node each — painted into the
|
|
10
|
+
owning window's double-buffered 2d context on ntk's frame clock.
|
|
11
|
+
|
|
12
|
+
## `style` and props
|
|
13
|
+
|
|
14
|
+
Everything CSS has a concept for goes in **`style`**; everything else is a
|
|
15
|
+
prop. No name means both, which is why `<window width>` is unambiguously the
|
|
16
|
+
real window's geometry. See [styling.md](styling.md) for arrays, state
|
|
17
|
+
blocks and `createStyles`.
|
|
18
|
+
|
|
19
|
+
```jsx
|
|
20
|
+
<box style={{ flexDirection: 'row', gap: 8 }} onClick={pick}>
|
|
21
|
+
<text style={{ fontSize: 14, color: '#2d3436' }}>hello</text>
|
|
22
|
+
</box>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Layout properties (all drawn elements + windows)
|
|
26
|
+
|
|
27
|
+
Lengths are logical pixels — a number, or the same number written `'12px'`
|
|
28
|
+
— and `'50%'` / `'auto'` pass through to yoga where it takes them. `em`,
|
|
29
|
+
`rem`, `vw`/`vh`/`vmin`/`vmax` and `calc()`/`min()`/`max()`/`clamp()` over
|
|
30
|
+
them resolve to pixels for the node they are written on, and again when what
|
|
31
|
+
they are relative to moves; a unit CSS has and react-x11 does not read (`ex`,
|
|
32
|
+
`cqw`, `pt`) is an error naming it. CSS's shorthands are read too —
|
|
33
|
+
`padding: '8px 16px'`, `border: '1px solid $border'`, `inset: 0`; see
|
|
34
|
+
[styling.md](styling.md#lengths-and-shorthands).
|
|
35
|
+
|
|
36
|
+
- **Size**: `width`, `height`, `minWidth`, `minHeight`, `maxWidth`,
|
|
37
|
+
`maxHeight`, `aspectRatio`. `<image>` and `<svg>` keep their aspect ratio
|
|
38
|
+
when only one of `width`/`height` is given
|
|
39
|
+
- **Flex**: `flexDirection` (`row`, `column`, `row-reverse`,
|
|
40
|
+
`column-reverse`), `justifyContent` (`flex-start`, `center`, `flex-end`,
|
|
41
|
+
`space-between`, `space-around`, `space-evenly`), `alignItems`,
|
|
42
|
+
`alignSelf`, `alignContent`, `flexWrap`, `flex`, `flexGrow`, `flexShrink`,
|
|
43
|
+
`flexBasis`, `gap`, `rowGap`, `columnGap`
|
|
44
|
+
- **Position**: `position` (`relative`, `absolute`, `static`, `sticky`, or
|
|
45
|
+
one registered with `registerPosition`), `top`, `right`, `bottom`,
|
|
46
|
+
`left`, `start`, `end`, and `inset` for all four. Under `sticky` the insets are how close to the
|
|
47
|
+
nearest scroll pane's edges the box may come, not offsets — see
|
|
48
|
+
[styling.md](styling.md#sticky-positioning) and
|
|
49
|
+
[custom positions](styling.md#custom-positions)
|
|
50
|
+
- **Grid**: `display: 'grid'` arranges the children as a CSS grid —
|
|
51
|
+
`gridTemplateColumns`, `gridTemplateRows`, `gridTemplateAreas`,
|
|
52
|
+
`gridAutoColumns`, `gridAutoRows`, `gridAutoFlow` and `justifyItems` on
|
|
53
|
+
the box, `gridColumn`, `gridRow`, `gridArea` and `justifySelf` on a child
|
|
54
|
+
— see [styling.md](styling.md#grid)
|
|
55
|
+
- **Arrangement**: `layout` (`grid`, `masonry`, `equal-row`, or one
|
|
56
|
+
registered with `registerLayout`) arranges the children in place of
|
|
57
|
+
flexbox, and a child's `layoutItem` is what it tells that layout — see
|
|
58
|
+
[styling.md](styling.md#custom-layouts)
|
|
59
|
+
- **Spacing**: `margin`, `marginTop/Right/Bottom/Left`, `padding`,
|
|
60
|
+
`paddingTop/Right/Bottom/Left`, and the logical pairs `paddingInline`,
|
|
61
|
+
`paddingBlock`, `marginInline`, `marginBlock`. `margin` and `padding` take
|
|
62
|
+
CSS's one-to-four values as one string: `'0 auto'`, `'8px 16px'`
|
|
63
|
+
- **Visibility**: `display` (`flex`, `grid`, `none`), `overflow` (`visible`,
|
|
64
|
+
`hidden`, `scroll`)
|
|
65
|
+
|
|
66
|
+
### Everything shrinks, nothing shrinks to nothing
|
|
67
|
+
|
|
68
|
+
`flexShrink` is `1`, as in CSS: an item hands back the space it has spare
|
|
69
|
+
when the row it is in runs short, rather than pushing the row past its
|
|
70
|
+
container. And **it never gives up what is inside it** — every item carries a
|
|
71
|
+
floor of its own min-content size, which is CSS's automatic minimum size
|
|
72
|
+
(`min-width: auto` on a flex item) written out by the renderer, since yoga
|
|
73
|
+
implements the shrinking half and not the floor half. A row of chips squeezes
|
|
74
|
+
until it wraps; a label squeezes to its longest word; a `height: 40` row
|
|
75
|
+
stays 40 tall.
|
|
76
|
+
|
|
77
|
+
Three ways a style says otherwise:
|
|
78
|
+
|
|
79
|
+
| write | to mean |
|
|
80
|
+
| --------------------------------- | ----------------------------------------- |
|
|
81
|
+
| `flexShrink: 0` | never give up any space at all |
|
|
82
|
+
| `minWidth: 0` / `minHeight: 0` | this may shrink past its content, to zero |
|
|
83
|
+
| `overflow: 'hidden'` / `'scroll'` | the same, and clip what no longer fits |
|
|
84
|
+
|
|
85
|
+
The last two are CSS's own rule — `min-*: auto` computes to `0` on anything
|
|
86
|
+
whose overflow is not `visible` — and they are what makes a scroll pane
|
|
87
|
+
possible: a viewport is a box that is _allowed_ to be smaller than what is
|
|
88
|
+
inside it. A `<box overflow="scroll">` gets `minWidth: 0` and `minHeight: 0`
|
|
89
|
+
for free.
|
|
90
|
+
|
|
91
|
+
One deliberate difference from CSS: **a size that was named is kept**. CSS
|
|
92
|
+
floors an item at `min(the size it was given, its content)`, so an empty
|
|
93
|
+
`height: 40` box squashes to nothing in a column too short for it; that is
|
|
94
|
+
survivable on the web because a `<div>` is a _block_ container and its
|
|
95
|
+
children are not flex items at all, and it is not survivable here, where
|
|
96
|
+
every box lays its children out with flex. Say `minHeight: 0` to get CSS's
|
|
97
|
+
answer for one.
|
|
98
|
+
|
|
99
|
+
The measurement costs the tree an extra layout pass per axis, on frames that
|
|
100
|
+
change the layout — a scroll, which moves no box, keeps the single pass it
|
|
101
|
+
always had.
|
|
102
|
+
|
|
103
|
+
### `flex`, the shorthand
|
|
104
|
+
|
|
105
|
+
`flex: 1` is `{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }` — "take this
|
|
106
|
+
share of what is left, from a base size of nothing" — which is what makes it
|
|
107
|
+
the right thing for a pane that should fill its window rather than its
|
|
108
|
+
content. `flex: 'auto'` grows and shrinks from the content's own size, and
|
|
109
|
+
`flex: 'none'` does neither. CSS's long form reads too — `'1 1 0'`,
|
|
110
|
+
`'0 0 240px'`, `'2 200px'`. A longhand written beside it wins, so
|
|
111
|
+
`{ flex: 1, flexBasis: 'auto' }` reads the way it does in CSS.
|
|
112
|
+
|
|
113
|
+
## Paint properties
|
|
114
|
+
|
|
115
|
+
- `backgroundColor` — any CSS color string (`'#2980b9'`,
|
|
116
|
+
`'rgba(0,0,0,.5)'`, `'red'`); `background` sets it and a gradient
|
|
117
|
+
(`backgroundImage`) in one
|
|
118
|
+
- any colour may be `'currentColor'`, the node's ink — its own `color` or
|
|
119
|
+
the one it inherits, followed when it moves
|
|
120
|
+
([styling.md](styling.md#currentcolor))
|
|
121
|
+
- `borderWidth`, `borderColor`, `borderRadius`, `borderStyle`
|
|
122
|
+
(`'solid'` default, `'dashed'`), or `border: '1px solid $border'` for the
|
|
123
|
+
first three — `border: '1px solid'` is drawn in `currentColor`
|
|
124
|
+
- per-side borders: `borderTopWidth`/`Right`/`Bottom`/`Left` override
|
|
125
|
+
`borderWidth` the way `paddingLeft` overrides `padding`, and
|
|
126
|
+
`borderTopColor`/… fall back to `borderColor`; `borderBottom: '1px solid
|
|
127
|
+
$border'` sets one side's pair. A blockquote's bar is
|
|
128
|
+
`borderLeftWidth: 3` on the quote box, not a 3px sibling; a table rule is
|
|
129
|
+
`borderBottomWidth: 1` on the row. `borderRadius` requires uniform
|
|
130
|
+
borders — a non-uniform border paints square
|
|
131
|
+
([styling.md](styling.md#per-side-borders))
|
|
132
|
+
- `zIndex` — paint/hit order among siblings (stable sort)
|
|
133
|
+
- `objectFit` — where an `<image>`'s or a `<video>`'s picture goes in its
|
|
134
|
+
content box, CSS's `object-fit`, centred: `'fill'` (`<image>`'s default),
|
|
135
|
+
`'contain'` (`<video>`'s), `'cover'`, `'none'`, `'scale-down'`
|
|
136
|
+
- `outlineWidth`, `outlineColor`, `outlineOffset` — the focus ring, painted
|
|
137
|
+
outside the border box and invisible to yoga, so it never moves what it
|
|
138
|
+
surrounds. A focusable node draws one on `:focus-visible` without being
|
|
139
|
+
asked; `outlineWidth: 0` — or `outline: 'none'` — opts out
|
|
140
|
+
([styling.md](styling.md#the-focus-ring))
|
|
141
|
+
- `opacity` — how opaque the node is **with everything inside it**, from 0
|
|
142
|
+
to 1: the subtree is drawn once and composited at that alpha, so a card, its
|
|
143
|
+
icon and its text fade as one. `0` draws nothing and is still hit
|
|
144
|
+
([styling.md](styling.md#opacity))
|
|
145
|
+
- `transition` — `120`, or `{ backgroundColor: 120, left: 200 }`: how long a
|
|
146
|
+
change to that property takes ([styling.md](styling.md#transitions))
|
|
147
|
+
- any value may be a **theme token**: `'$panel'` resolves against the nearest
|
|
148
|
+
`theme` prop above the node ([styling.md](styling.md#theme-tokens))
|
|
149
|
+
- `'@width >= 600'` blocks restyle for the window's size, layout included
|
|
150
|
+
([styling.md](styling.md#window-size-queries))
|
|
151
|
+
|
|
152
|
+
`cursor` (`'pointer'`, `'text'`, `'wait'`, `'move'`, `'crosshair'`, resize
|
|
153
|
+
arrows, … — the ntk cursor name map) and `pointerEvents` are style too: CSS
|
|
154
|
+
has both, and React Native has been moving `pointerEvents` the same way.
|
|
155
|
+
`pointerEvents: 'none'` takes a node and its subtree out of hit testing;
|
|
156
|
+
`'box-none'`, React Native's, takes out only the node itself — its children
|
|
157
|
+
still take the pointer, and a press on its own area goes to what is behind
|
|
158
|
+
it. That is the value for an overlay of controls that must not swallow the
|
|
159
|
+
clicks between them, a `<glarea>`'s children over a scene whose input lives
|
|
160
|
+
on the element behind the surface included. So is `hitSlop`:
|
|
161
|
+
|
|
162
|
+
```jsx
|
|
163
|
+
<box style={{ height: 16, hitSlop: { top: 4, bottom: 4 } }} /> // 24px target
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`hitSlop: 4` grows every side, the object form only the sides it names. It
|
|
167
|
+
is **hit testing and nothing else** — not paint, not yoga — which is what
|
|
168
|
+
lets a 16px control answer over the 24px WCAG 2.2 SC 2.5.8 wants without a
|
|
169
|
+
taller control misaligning the row it sits in. The slop may overlap a
|
|
170
|
+
sibling's box; hit testing runs front to back over paint order, so the
|
|
171
|
+
sibling on top keeps its own pixels either way.
|
|
172
|
+
|
|
173
|
+
## Interaction props
|
|
174
|
+
|
|
175
|
+
`focusable`, `tabIndex` (sequential focus order; `-1` is focusable but not
|
|
176
|
+
tabbable), `autoFocus`, `trapFocus` (own a focus scope — Tab and presses
|
|
177
|
+
stay inside it, focus is restored when it unmounts), `disabled` (never
|
|
178
|
+
focusable, and the trigger for a `:disabled` style block), and the event
|
|
179
|
+
handlers listed in [events.md](events.md).
|
|
180
|
+
|
|
181
|
+
`onLayout` is the one handler that reports layout rather than input: after a
|
|
182
|
+
layout pass moved or resized the element it gets `{x, y, width, height}` in
|
|
183
|
+
logical pixels — the position within the parent _as laid out_, so a scroll
|
|
184
|
+
does not fire it — once after the first layout and then only when something
|
|
185
|
+
changed. It arrives after the pass, so state set from it is safe. It is how
|
|
186
|
+
a component reacts to its size when the reaction is not a style
|
|
187
|
+
([react-features.md](react-features.md#measuring-a-node)); where it is one,
|
|
188
|
+
a `'@container'` block answers in the same frame
|
|
189
|
+
([styling.md](styling.md#container-queries)).
|
|
190
|
+
|
|
191
|
+
Every element also takes `role` and the `aria-*` props — the web's
|
|
192
|
+
accessibility vocabulary, read by the built-in AT-SPI bridge so screen
|
|
193
|
+
readers see the tree. They are inert where no assistive technology is
|
|
194
|
+
listening, and the defaults already say something sensible for every
|
|
195
|
+
element: [accessibility.md](accessibility.md).
|
|
196
|
+
|
|
197
|
+
Drag and drop is two more prop families on the same elements —
|
|
198
|
+
`dropAccept` + `onDrop` to accept a drop, `draggable` + `dragData` to start
|
|
199
|
+
a drag — and works with other applications as well as inside the app.
|
|
200
|
+
Their presence is what registers the node, so there is nothing else to
|
|
201
|
+
mount: [drag-and-drop.md](drag-and-drop.md).
|
|
202
|
+
|
|
203
|
+
`selectable` makes an element a **selection surface**: text inside it can be
|
|
204
|
+
dragged over, copied and, on X11, handed to the PRIMARY selection. See
|
|
205
|
+
[Selecting text](#selecting-text) below.
|
|
206
|
+
|
|
207
|
+
`scale` zooms a subtree, CSS `zoom` rather than a transform: every length
|
|
208
|
+
under the element — and the element's own style — is multiplied by it, and
|
|
209
|
+
text is shaped at the size it will be drawn at. It is what an element owning
|
|
210
|
+
a viewport (a graph pane, a map) mounts its content inside. Windows take no
|
|
211
|
+
`scale`: [scale.md](scale.md#a-subtree-of-its-own).
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## `<window>`
|
|
216
|
+
|
|
217
|
+
A real toplevel window — an X11 window, or an `NSWindow` on the Cocoa
|
|
218
|
+
backend; the flex, paint and event root for its subtree.
|
|
219
|
+
|
|
220
|
+
| prop | |
|
|
221
|
+
| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
222
|
+
| `title` | window title (UTF-8, via `WM_NAME` + `_NET_WM_NAME`) |
|
|
223
|
+
| `width`, `height`, `x`, `y` | window geometry (window state, not yoga style — the user may resize). A size is pixels or `'auto'`, and leaving it out means `'auto'` (below). A change sends only what changed: a new size leaves the window where the window manager or the user put it |
|
|
224
|
+
| `backgroundColor` | full-window clear color (default white) |
|
|
225
|
+
| `onResize(ev)` | ConfigureNotify — the tree reflows automatically. Fires for **moves** too (see below) |
|
|
226
|
+
| `onExpose(ev)` | after a repaint was required |
|
|
227
|
+
| `onCloseRequest(ev)` | WM close button — replaces the default answer (see below); `WM_DELETE_WINDOW` is advertised either way |
|
|
228
|
+
| `states` | EWMH `_NET_WM_STATE` — controlled, see below |
|
|
229
|
+
| `fullscreen`, `alwaysOnTop` | boolean sugar for two of those states |
|
|
230
|
+
| `decorations` | `false` asks the WM for no titlebar or border |
|
|
231
|
+
| `transparent` | 32-bit ARGB visual — rounded, translucent windows (below) |
|
|
232
|
+
| `transientFor` | ICCCM `WM_TRANSIENT_FOR` — the window this one belongs to (below) |
|
|
233
|
+
| `onStatesChange(states)` | what the window manager actually did |
|
|
234
|
+
| `onClientMessage(ev)` | a ClientMessage addressed to this window — EWMH, XEmbed, the tray (below) |
|
|
235
|
+
| `theme` | palette that `$token` style values resolve against, for this subtree |
|
|
236
|
+
| `embeddable` | created but never self-mapped: the window waits for an embedder ([`<foreign>`](embedding.md) on the other side), which maps it after the reparent. What a [`<Frame>`](frame.md) pane's root window sets |
|
|
237
|
+
| `hidden` | realized and laid out, never mapped while true (below) |
|
|
238
|
+
| `frameRate` | how the window paces its frames under a stream of changes: `'display'` (the default), `'adaptive'`, `'throughput'`, a ceiling in fps, or `{ budget, minFps, maxFps }` (below) |
|
|
239
|
+
|
|
240
|
+
Windows may be nested inside other windows (real X11 child windows).
|
|
241
|
+
**Ref**: the live window object — on X11 the ntk `Window`, with
|
|
242
|
+
`getContext('2d')`, `requestAnimationFrame`, `setCursor` and the whole ntk
|
|
243
|
+
API behind it.
|
|
244
|
+
|
|
245
|
+
#### On the Cocoa backend
|
|
246
|
+
|
|
247
|
+
Everything above that is about _drawing_ — geometry, background, resize,
|
|
248
|
+
close, `transparent`, `theme`, `frameRate`, `hidden` — works. What a
|
|
249
|
+
**window manager** does is where the two part company, because macOS has no
|
|
250
|
+
separate one to negotiate with, and the honest list is short:
|
|
251
|
+
|
|
252
|
+
| prop | on Cocoa |
|
|
253
|
+
| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
254
|
+
| `minWidth`/`minHeight`/`maxWidth`/`maxHeight` | honoured — they become the window's own min/max size |
|
|
255
|
+
| `resizable`, `decorations` | honoured, but read **once, at creation**: they are `NSWindow` style options rather than hints, so changing either after mount does nothing |
|
|
256
|
+
| `transparent`, `borderRadius` | always available; every window composites, so there is no "if a compositor is running" |
|
|
257
|
+
| `states: ['demands_attention']` | the Dock bounce, held until the state is removed |
|
|
258
|
+
| `fullscreen`, `states: ['maximized' \| 'hidden']` | the green button's full-screen Space, the zoom, and the Dock, through `toggleFullScreen:`, `zoom:` and `miniaturize:` |
|
|
259
|
+
| every other `_NET_WM_STATE` | inert — `sticky`, `below`, `skip_taskbar`, `shaded` and the rest have no AppKit equivalent |
|
|
260
|
+
| `onStatesChange`, and `useWindowState()` | fire for full screen (from the transition's begin), the zoom, the Dock and the key window; `desktop` and `obscured` never change |
|
|
261
|
+
| `appId`, `windowType`, `gravity`, the increment and aspect hints | inert. They are messages to a window manager, and there is not one |
|
|
262
|
+
| `transientFor` | inert today — a stub waiting on `addChildWindow`; a managed `<Dialog>` already floats on its own window |
|
|
263
|
+
| `onClientMessage` | never fires — X11's ClientMessage has no counterpart |
|
|
264
|
+
| `embeddable` | means a `<Frame>` pane, and works — but through shared surfaces rather than reparenting ([frame.md](frame.md)) |
|
|
265
|
+
|
|
266
|
+
Inert means **inert, not fatal, and today also silent**: the prop is
|
|
267
|
+
accepted and does nothing, so shared application code stays branch-free. See
|
|
268
|
+
[macos.md](macos.md#windowing-semantics-what-maps-what-bends-what-breaks)
|
|
269
|
+
for why that is the policy and what it costs.
|
|
270
|
+
|
|
271
|
+
### Natural size
|
|
272
|
+
|
|
273
|
+
A window with no `width`/`height` is **sized from its content and capped at
|
|
274
|
+
the screen**. `'auto'` says the same thing out loud, and the two axes are
|
|
275
|
+
independent:
|
|
276
|
+
|
|
277
|
+
```jsx
|
|
278
|
+
<window title="prefs" /> {/* natural, both ways */}
|
|
279
|
+
<window width={600} height="auto" /> {/* height follows the content */}
|
|
280
|
+
<window width="auto" maxWidth={720} minHeight={200} /> {/* auto within your own bounds */}
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
This is CSS's `width: auto`, read the way CSS reads it for a box whose
|
|
284
|
+
containing block is the viewport but which is not in flow — a float, an
|
|
285
|
+
abspos, an inline-block: **shrink-to-fit**. A top-level window has no
|
|
286
|
+
container to stretch into, and stretching into the screen is what
|
|
287
|
+
`fullscreen` means.
|
|
288
|
+
|
|
289
|
+
The size is worked out **before `CreateWindow`**, so the window is created
|
|
290
|
+
the right size rather than resized into it after mapping. Nothing is ever on
|
|
291
|
+
screen at the wrong size, and there is no jump to watch for.
|
|
292
|
+
|
|
293
|
+
**How it is measured**, which is worth knowing because the second step is
|
|
294
|
+
where the surprises are:
|
|
295
|
+
|
|
296
|
+
1. The tree is laid out with no available width, so nothing wraps and the
|
|
297
|
+
root reports its **max-content** width.
|
|
298
|
+
2. That is clamped into `[minWidth, min(maxWidth, the screen)]` — the same
|
|
299
|
+
size-hint props the window manager enforces also bound the auto size,
|
|
300
|
+
exactly as `min-width`/`max-width` bound `width: auto` in CSS. Where the
|
|
301
|
+
two disagree, `minWidth` wins, which is CSS's order too.
|
|
302
|
+
3. The tree is laid out **again at that width**, and the height comes from
|
|
303
|
+
there. A paragraph that had to wrap is taller than step 1 said.
|
|
304
|
+
|
|
305
|
+
Where this parts company with CSS: shrink-to-fit is
|
|
306
|
+
`min(max(min-content, available), max-content)`, and that `max(…)` means a
|
|
307
|
+
CSS box never shrinks below its min-content size even when it overflows. A
|
|
308
|
+
window cannot be wider than the screen, so the cap wins and the content is
|
|
309
|
+
cut instead.
|
|
310
|
+
|
|
311
|
+
**The cap** is the usable area of the monitor the window will open on:
|
|
312
|
+
Xinerama's per-monitor rects — so a window on a two-head desktop is bounded
|
|
313
|
+
by one screen and not by both — with `_NET_WORKAREA` taken off it per axis,
|
|
314
|
+
so a panel is not space to grow into. Both are read once while `createRoot()`
|
|
315
|
+
connects. On a server with neither, the cap is the screen; with no display to
|
|
316
|
+
ask at all — the headless mock — there is no cap. The window manager's frame
|
|
317
|
+
is _not_ modelled, so a window that reaches the cap is a titlebar taller than
|
|
318
|
+
the work area and the WM will trim it.
|
|
319
|
+
|
|
320
|
+
**Afterwards, auto keeps up with the content until something else sets the
|
|
321
|
+
size.** Add a row and the window grows, take it away and the window shrinks
|
|
322
|
+
back; the moment the user drags an edge, or a window manager answers with a
|
|
323
|
+
size of its own, the window is theirs and stops re-fitting. Setting `width` to
|
|
324
|
+
a number is the app doing the same thing, and setting it back to `'auto'`
|
|
325
|
+
hands it back. Only a size the window did not ask for counts: its own
|
|
326
|
+
requests come back from the server as echoes, sometimes late and sometimes
|
|
327
|
+
behind a move that still carries the old size, and none of those is anybody
|
|
328
|
+
taking the window over.
|
|
329
|
+
|
|
330
|
+
**A re-fit is laid out once, at the new size.** On X11 a window takes a size
|
|
331
|
+
when the server answers the request, a round trip later, so the frame that
|
|
332
|
+
re-fits waits for that answer before it lays out, and the old pixels stay
|
|
333
|
+
up meanwhile. Laid out at the size it still had, content that grew would be
|
|
334
|
+
drawn for a round trip wrapped and cut off at the bottom of a window too
|
|
335
|
+
small for it — a tooltip whose value gained a word. A request answered with
|
|
336
|
+
no new size (a window manager refusing it) gives up the wait after 150 ms.
|
|
337
|
+
Cocoa takes a size on the call, so there is nothing to wait for there.
|
|
338
|
+
`REACT_X11_NO_REFIT_HOLD=1` turns the wait off
|
|
339
|
+
([debugging.md](debugging.md#react_x11_no_refit_hold1)).
|
|
340
|
+
|
|
341
|
+
On the Cocoa backend a window is a whole number of **points**, so content
|
|
342
|
+
100.5 points tall makes a window 101 points tall — rounded up, so nothing is
|
|
343
|
+
cut — and that is still the window's own size rather than one somebody else
|
|
344
|
+
chose: it goes on following its content.
|
|
345
|
+
|
|
346
|
+
### A floor the content decides
|
|
347
|
+
|
|
348
|
+
`minWidth`/`minHeight` also take `'auto'`: **the smallest size the content
|
|
349
|
+
can be drawn at**, measured from the tree and handed to the window manager,
|
|
350
|
+
which is what stops the user dragging the window below it.
|
|
351
|
+
|
|
352
|
+
```jsx
|
|
353
|
+
<window minWidth="auto" minHeight="auto"> {/* never smaller than my content */}
|
|
354
|
+
<window width={900} minWidth="auto" /> {/* my opening size, the content's floor */}
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
This is what Qt and GTK both do, and in both it is the default rather than
|
|
358
|
+
something you ask for: a Qt layout's `minimumSize()` becomes the window's
|
|
359
|
+
via `QLayout::SetDefaultConstraint`, and GTK writes the widget tree's
|
|
360
|
+
minimum into the WM geometry hints. Here it is opt-in, because a renderer
|
|
361
|
+
whose layout is CSS has no floor unless one is asked for — CSS lets content
|
|
362
|
+
overflow.
|
|
363
|
+
|
|
364
|
+
**How the floor is measured**: the tree is laid out with _no room at all_,
|
|
365
|
+
and the floor is how far it still reached. What each node contributes is
|
|
366
|
+
whatever its own style lets it shrink to, which makes the interesting part
|
|
367
|
+
what _doesn't_ count:
|
|
368
|
+
|
|
369
|
+
| the node | contributes |
|
|
370
|
+
| --------------------------------- | ---------------- |
|
|
371
|
+
| a box that named a size | that size |
|
|
372
|
+
| a wrapping row | its widest item |
|
|
373
|
+
| text | its longest word |
|
|
374
|
+
| `overflow: 'scroll'` / `'hidden'` | nothing |
|
|
375
|
+
| a `minWidth: 0` of its own | nothing |
|
|
376
|
+
| a `minWidth: 240` of its own | 240 |
|
|
377
|
+
|
|
378
|
+
A registered element contributes whatever its `measureContent` answers when
|
|
379
|
+
asked for the smallest size it can be drawn at — see
|
|
380
|
+
[extending.md](extending.md#a-size-of-your-own).
|
|
381
|
+
|
|
382
|
+
This is the same measurement every node's own floor comes from (above), read
|
|
383
|
+
at the top of the tree — so the escape hatch is the same one, and it is the
|
|
384
|
+
one CSS, Qt and GTK all spell: `min-width: 0`, `QScrollArea`,
|
|
385
|
+
`min-content-width`. A node that named a floor of its own is taken at its
|
|
386
|
+
word and its contents stop counting, so a window around a scrolling pane is
|
|
387
|
+
floored by everything _except_ that pane.
|
|
388
|
+
|
|
389
|
+
`maxWidth`/`maxHeight` take `'auto'` as well, and it means the other half of
|
|
390
|
+
the pair: **the size the content wanted** — the natural size above, not the
|
|
391
|
+
floor. `'auto'` reads as "ask the content", and the content's answer to _how
|
|
392
|
+
big_ is not its answer to _how small_. On a tree where nothing can shrink
|
|
393
|
+
the two are the same number, which is what makes `minWidth="auto"
|
|
394
|
+
maxWidth="auto"` the fixed-size dialog — the same thing `resizable={false}`
|
|
395
|
+
says more briefly.
|
|
396
|
+
|
|
397
|
+
Three things worth knowing:
|
|
398
|
+
|
|
399
|
+
- **A floor is a request.** mutter, kwin and xfwm enforce it at the drag; a
|
|
400
|
+
tiling window manager may size the window however it likes. It stops the
|
|
401
|
+
user, it does not relieve the app of clipping gracefully — and the
|
|
402
|
+
renderer will not fight a window manager that ignores it.
|
|
403
|
+
- **`minHeight="auto"` is measured for the width the window has**, and
|
|
404
|
+
re-sent as that changes. A minimum height is a height _for a width_ — a
|
|
405
|
+
paragraph is tallest at its narrowest — and `WM_NORMAL_HINTS` holds two
|
|
406
|
+
independent numbers with no way to say so. (GTK answers the same question
|
|
407
|
+
at its minimum _width_, which is a taller floor than a wide window needs.)
|
|
408
|
+
- **The floor outlives the size.** An `'auto'` size stops tracking once the
|
|
409
|
+
user takes the window over; an `'auto'` bound does not, because stopping
|
|
410
|
+
them going too far is the whole job. It is measured on every frame that
|
|
411
|
+
lays out — one extra layout pass per axis asked for — and written only on
|
|
412
|
+
the frames it actually moves.
|
|
413
|
+
|
|
414
|
+
Three things worth knowing before reaching for it:
|
|
415
|
+
|
|
416
|
+
- A scroll container reports its **content** height, not a viewport height,
|
|
417
|
+
so an auto window around a long list opens as tall as the screen. Give the
|
|
418
|
+
window a `height`, or the scrolling box a `maxHeight`, if the scrolling is
|
|
419
|
+
meant to happen inside a smaller window.
|
|
420
|
+
- The natural size is measured with **no available width**, where nothing
|
|
421
|
+
wraps and nothing is short of room to shrink into, so an auto width is the
|
|
422
|
+
**unwrapped** row. That makes the width font-dependent: the same window is
|
|
423
|
+
wider under a wide UI face than under a narrow one.
|
|
424
|
+
- Measuring costs an extra layout pass per frame that lays out, for as long
|
|
425
|
+
as the window is still tracking. A window with both sizes given pays
|
|
426
|
+
nothing.
|
|
427
|
+
|
|
428
|
+
### `hidden` — realized, laid out, not on screen
|
|
429
|
+
|
|
430
|
+
```jsx
|
|
431
|
+
<window hidden={minimized} title="scratch" />
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
The X window exists at its size — auto sizes included, the content is really
|
|
435
|
+
measured — and the tree behind it is live; the window is simply never mapped
|
|
436
|
+
while `hidden` is true. Clearing it maps the window where it stands. Unlike
|
|
437
|
+
conditional rendering nothing unmounts in between, so component state,
|
|
438
|
+
subscriptions and the X window itself all survive a hide: this is the
|
|
439
|
+
declarative form of "minimize by unmapping", and what
|
|
440
|
+
[`Tooltip`](components.md#tooltip) measures an element label in before
|
|
441
|
+
placing it.
|
|
442
|
+
|
|
443
|
+
A subtree hidden by React — `<Activity mode="hidden">`, a `<Suspense>` that
|
|
444
|
+
suspended — composes rather than competes: the window is on screen only
|
|
445
|
+
while **neither** says hidden. Hiding releases the keyboard focus of
|
|
446
|
+
anything inside (the [focus-follows-visibility](events.md) rule), and the
|
|
447
|
+
reveal hands it back unless something else took it meanwhile.
|
|
448
|
+
|
|
449
|
+
On a `<popup>` the pair of unmap/map is the whole story. On a managed
|
|
450
|
+
`<window>` an unmap is ICCCM's _withdraw_ — the window manager forgets the
|
|
451
|
+
window, and the re-map is a fresh `MapRequest`, so a WM may frame or place
|
|
452
|
+
it anew and `states` are re-declared rather than remembered.
|
|
453
|
+
|
|
454
|
+
### `frameRate` — pacing the frames under a flood
|
|
455
|
+
|
|
456
|
+
```jsx
|
|
457
|
+
<window frameRate="adaptive">…</window>
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
By default a window paints every frame the clock gives it: on X11 as often
|
|
461
|
+
as the server keeps up, on macOS at the display's own rate. That is right
|
|
462
|
+
for a UI that answers input — a keystroke, a hover, a scroll — where every
|
|
463
|
+
frame is one someone is waiting for. It is wrong for a window whose content
|
|
464
|
+
changes faster than anyone can read it: a terminal under `cat`, a chart fed
|
|
465
|
+
by a socket, a simulation ticking as fast as it can. On a 120Hz panel such a
|
|
466
|
+
window paints 120 times a second, and where a frame costs a few
|
|
467
|
+
milliseconds of the JS thread — full-window CoreGraphics passes on macOS —
|
|
468
|
+
most of that thread goes to painting screens nobody sees, and the producer
|
|
469
|
+
gets what is left. Measured on a streaming terminal, that was a flood four
|
|
470
|
+
times slower on macOS than under XQuartz on the same machine, and the whole
|
|
471
|
+
difference was frame count times per-frame cost.
|
|
472
|
+
|
|
473
|
+
`frameRate` prices frames in CPU time rather than counting them. A window
|
|
474
|
+
under `'adaptive'` keeps a budget: painting may take a quarter of the wall
|
|
475
|
+
time while the window is busy. Credit accrues at that rate, a frame spends
|
|
476
|
+
what it measurably cost — the flush, and on macOS the present — and a claim
|
|
477
|
+
that finds the window in debt waits for the credit to reach zero, and no
|
|
478
|
+
longer. Four things follow, and they are what the tests pin:
|
|
479
|
+
|
|
480
|
+
- **Idle is immediate.** A quiet window has a full bucket, so the first
|
|
481
|
+
claim after a pause — a keystroke, a click — paints on the next tick,
|
|
482
|
+
whatever the last frame cost.
|
|
483
|
+
- **Cheap is unthrottled.** A frame that costs less than the credit it
|
|
484
|
+
accrued leaves the bucket where it was. A blit-scrolled list keeps the
|
|
485
|
+
display's rate: its frames are memmoves and a strip.
|
|
486
|
+
- **A rare expensive frame is free.** The bucket holds a burst — 50ms of
|
|
487
|
+
paint, the floor's interval — so the one relayout in a scroll costs
|
|
488
|
+
credit, the credit refills, and nothing waited. Only a _stream_ of
|
|
489
|
+
expensive frames drains it, and then each waits `cost × (1/budget − 1)`:
|
|
490
|
+
a 6ms frame is followed by 18ms of the thread for everything else.
|
|
491
|
+
- **A flood that ends is un-throttled at once.** The debt is never more than
|
|
492
|
+
one frame's cost, so the prompt that appears when the flood stops waits
|
|
493
|
+
for that and nothing more.
|
|
494
|
+
|
|
495
|
+
Two more numbers bound the rule. `minFps` is the floor: whatever the debt,
|
|
496
|
+
the screen is never more than `1000 / minFps` ms behind the last paint — a
|
|
497
|
+
promise about staleness, not a rate, since a single frame can take longer.
|
|
498
|
+
`maxFps` is a ceiling on top of the display's, cheap frames included — the
|
|
499
|
+
knob every terminal already has.
|
|
500
|
+
|
|
501
|
+
| value | `budget` | `minFps` | `maxFps` | when |
|
|
502
|
+
| ---------------------------- | -------: | -------: | -------: | -------------------------------------------------------------------------------------- |
|
|
503
|
+
| `'display'` (the default) | 1 | none | none | a UI answering input: every frame the clock gives, after React's own batching |
|
|
504
|
+
| `'adaptive'` | 0.25 | 20 | none | a window fed off an input path: a terminal, a live chart, a scene that streams |
|
|
505
|
+
| `'throughput'` | 0.1 | 10 | 30 | output matters more than the display: the screen may lag by up to 100ms during a flood |
|
|
506
|
+
| a number, `60` | 1 | none | 60 | a ceiling and nothing else — the fixed cap |
|
|
507
|
+
| `{ budget, minFps, maxFps }` | | | | the three numbers, any subset; the ones left out are `'display'`'s |
|
|
508
|
+
|
|
509
|
+
A budget below 1 with no `minFps` warns in development: after an expensive
|
|
510
|
+
frame the next may wait three times its cost with nothing to bound it, and
|
|
511
|
+
`{ budget, minFps: 20 }` is what `'adaptive'` does.
|
|
512
|
+
|
|
513
|
+
Three sources, in order of precedence: **`REACT_X11_FRAME_RATE`** in the
|
|
514
|
+
environment (`adaptive`, `display`, `throughput` or a number) overrides
|
|
515
|
+
everything, the way `REACT_X11_BACKEND` does, so an A/B run and a field
|
|
516
|
+
diagnosis need no code change; then **the prop**; then
|
|
517
|
+
**`createRoot({ frameRate })`**, the default for every window of that root;
|
|
518
|
+
then `'display'`. A `<popup>` paces itself the same way, from its own prop.
|
|
519
|
+
A [`<glarea>`](#glarea) takes the window's policy for its own frames unless
|
|
520
|
+
it names one.
|
|
521
|
+
|
|
522
|
+
What the pacer does not do. It never changes what a frame paints: a held
|
|
523
|
+
claim's damage accumulates on the window exactly as it does between two
|
|
524
|
+
ticks of the clock, and the frame that follows paints all of it. It never
|
|
525
|
+
holds the answer to an input: a click or a key paints the window at once
|
|
526
|
+
(the early flush in [events.md](events.md)), held claims included, and the
|
|
527
|
+
debt is paid then. And it prices the JS thread's time only — on X11 the
|
|
528
|
+
server's work is fenced by the frame clock already, so the pacer rarely
|
|
529
|
+
holds a frame there; where a backend has backpressure of its own, the
|
|
530
|
+
pacer is inert. `REACT_X11_TRACE=requests` prints `waited=` on every frame
|
|
531
|
+
the pacer held ([debugging.md](debugging.md)), and
|
|
532
|
+
[architecture/frame-pacing.md](architecture/frame-pacing.md) is the design
|
|
533
|
+
record, with the measurements.
|
|
534
|
+
|
|
535
|
+
### `onResize` fires for moves
|
|
536
|
+
|
|
537
|
+
`onResize` is X's `ConfigureNotify`, which reports _any_ geometry change:
|
|
538
|
+
under a window manager that moves windows opaquely, dragging one by its
|
|
539
|
+
title bar delivers one per pointer step, all the same size. The renderer
|
|
540
|
+
already discriminates — a move costs no layout and no repaint — but a
|
|
541
|
+
handler of your own has to, or it does its work per step of a drag. The
|
|
542
|
+
event says which it was:
|
|
543
|
+
|
|
544
|
+
```jsx
|
|
545
|
+
<window
|
|
546
|
+
onResize={(ev) => {
|
|
547
|
+
if (ev.resized) refit(ev.width, ev.height);
|
|
548
|
+
if (ev.moved) rememberPosition(ev.x, ev.y);
|
|
549
|
+
}}
|
|
550
|
+
/>
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
`ev.previous` carries the geometry these are measured against. Note that
|
|
554
|
+
`ev.x`/`ev.y` are frame-relative once a reparenting window manager has
|
|
555
|
+
framed the window, so they are not screen coordinates — the renderer
|
|
556
|
+
resolves the real origin itself for popup anchoring.
|
|
557
|
+
|
|
558
|
+
### Stacking
|
|
559
|
+
|
|
560
|
+
Nested `<window>`s stack the way drawn siblings paint: the later sibling
|
|
561
|
+
sits on top, and `zIndex` wins over document order. Reordering them in JSX
|
|
562
|
+
restacks the real windows — one `ConfigureWindow` pass per commit, and
|
|
563
|
+
nothing at all when mount order already agrees.
|
|
564
|
+
|
|
565
|
+
```jsx
|
|
566
|
+
<window>
|
|
567
|
+
<window key="back" /> {/* bottom */}
|
|
568
|
+
<window key="front" /> {/* on top */}
|
|
569
|
+
<window key="always" zIndex={1} /> {/* above both, wherever it sits here */}
|
|
570
|
+
</window>
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
Top-level windows are the window manager's to stack — it redirects the
|
|
574
|
+
request — so their order in the tree carries no stacking meaning. Use
|
|
575
|
+
`alwaysOnTop` (below) for those.
|
|
576
|
+
|
|
577
|
+
### Window manager hints
|
|
578
|
+
|
|
579
|
+
Properties the window manager reads (ntk ≥ 3.5.0). All work at mount and
|
|
580
|
+
update; unchanged values are not re-sent. The size hints are flat props like
|
|
581
|
+
the geometry they constrain — with style in its own channel the yoga names
|
|
582
|
+
are free, so no `sizeHints` object is needed.
|
|
583
|
+
|
|
584
|
+
| prop | |
|
|
585
|
+
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
|
586
|
+
| `resizable` | `false` pins min and max size to the current size |
|
|
587
|
+
| size hints | `minWidth`, `minHeight`, `maxWidth`, `maxHeight`, `widthInc`, `heightInc`, `baseWidth`, `baseHeight`, `minAspect`, `maxAspect`, `gravity` |
|
|
588
|
+
| `appId` | `'instance'`, `['instance', 'Class']` or `{instance, class}` |
|
|
589
|
+
| `windowType` | `'dialog'`, `'utility'`, `'tooltip'`… or an array of fallbacks |
|
|
590
|
+
| `decorations` | `false` asks for no titlebar or border |
|
|
591
|
+
|
|
592
|
+
```jsx
|
|
593
|
+
<window width={400} height={300} resizable={false} windowType="dialog" />
|
|
594
|
+
<window width={400} height={300} minWidth={320} minHeight={200} />
|
|
595
|
+
<window width={400} height={300} minWidth="auto" /> {/* measured — see above */}
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
The four bounds also take `'auto'`, which asks the content instead of naming
|
|
599
|
+
a number: [a floor the content decides](#a-floor-the-content-decides).
|
|
600
|
+
|
|
601
|
+
On a `<window>` these names are the window's, not yoga's: `width`/`height`
|
|
602
|
+
are the real geometry the user can drag, and `minWidth`/`maxHeight` are what
|
|
603
|
+
the WM enforces. A window's _contents_ are laid out by its `style`. The size
|
|
604
|
+
hints do double duty on an auto-sized window, where they also bound the
|
|
605
|
+
natural size (above) — so `resizable={false}` pins the window at whatever its
|
|
606
|
+
content measured, which is the fixed-size dialog:
|
|
607
|
+
|
|
608
|
+
```jsx
|
|
609
|
+
<window resizable={false} windowType="dialog" />
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
### `transientFor` — a window that belongs to another
|
|
613
|
+
|
|
614
|
+
Without it, every secondary top-level window an app opens is, to the window
|
|
615
|
+
manager, an unrelated second application: its own taskbar and alt-tab entry,
|
|
616
|
+
placed wherever new windows go, stacked independently, and it does not
|
|
617
|
+
minimise with the window it belongs to.
|
|
618
|
+
|
|
619
|
+
```jsx
|
|
620
|
+
const main = useRef(null);
|
|
621
|
+
|
|
622
|
+
<window ref={main} title="editor">…</window>
|
|
623
|
+
<window transientFor={main} windowType="dialog" title="Preferences">…</window>
|
|
624
|
+
```
|
|
625
|
+
|
|
626
|
+
Takes a ref to a `<window>`/`<popup>`, a ref to **any drawn node** (resolved
|
|
627
|
+
to the window that owns it — so an out-of-flow `<box>` inside the owner is a
|
|
628
|
+
perfectly good handle), a raw XID, `'root'` for "transient for this client's
|
|
629
|
+
whole window group", or `null` to clear.
|
|
630
|
+
|
|
631
|
+
Measured against quartz-wm — the thinnest EWMH implementation in the stack,
|
|
632
|
+
so this is a floor rather than a best case:
|
|
633
|
+
|
|
634
|
+
| the client sets | the WM allows | the WM adds to `_NET_WM_STATE` |
|
|
635
|
+
| --------------------- | ------------------------------------------------------------- | ------------------------------ |
|
|
636
|
+
| nothing | close, minimize, move, resize, maximize horz/vert, fullscreen | — |
|
|
637
|
+
| `transientFor` | close, minimize, move, resize | skip_taskbar, skip_pager |
|
|
638
|
+
| `windowType="dialog"` | close, minimize, move, resize | skip_taskbar, skip_pager |
|
|
639
|
+
|
|
640
|
+
Rows two and three matching is the EWMH rule working as specified: a managed
|
|
641
|
+
window with `WM_TRANSIENT_FOR` and no `_NET_WM_WINDOW_TYPE` **is** a dialog.
|
|
642
|
+
The converse does not hold — a `dialog` type with no owner is a dialog
|
|
643
|
+
belonging to nobody, with nothing to stack above or iconify with — so set
|
|
644
|
+
both, which is what real toolkits do. A fuller window manager also restacks
|
|
645
|
+
the transient above its owner and iconifies it alongside; quartz-wm does
|
|
646
|
+
neither, and both are policy rather than spec.
|
|
647
|
+
|
|
648
|
+
**It is inert on an override-redirect window**, which is every `<popup>` by
|
|
649
|
+
default: the WM never manages those, so nothing reads the property. ICCCM
|
|
650
|
+
4.1.2.6 draws exactly that distinction — `WM_TRANSIENT_FOR` for windows the
|
|
651
|
+
WM manages, override-redirect plus a pointer grab for menus. ntk warns if
|
|
652
|
+
you ask it to write the property on one.
|
|
653
|
+
|
|
654
|
+
Resolution happens in the **commit phase**, not at element creation: a React
|
|
655
|
+
ref is not something ntk should be asked to understand. Refs attach in the
|
|
656
|
+
layout phase, after every mutation, so on the commit that mounts two sibling
|
|
657
|
+
`<window>`s the second realizes while the first one's ref is still null —
|
|
658
|
+
that owner is retried on the frame the mount schedules rather than dropped.
|
|
659
|
+
|
|
660
|
+
`windowIdOf(refOrInstance)` is the same resolution, exported: it returns the
|
|
661
|
+
XID a ref points at, or null. It is also what an xdg-desktop-portal
|
|
662
|
+
`parent_window` handle needs — `` `x11:${id.toString(16)}` ``, lowercase hex
|
|
663
|
+
with **no** `0x` prefix. Both shipping portal backends happen to tolerate a
|
|
664
|
+
prefix, which is exactly why it is easy to get wrong and never notice; Qt's
|
|
665
|
+
parser returns 0 on failure with no error path, so the symptom would be a
|
|
666
|
+
silently unparented, non-modal dialog. `useWindowId(ref)` is the hook form
|
|
667
|
+
and returns a getter, like `useAnchor`.
|
|
668
|
+
|
|
669
|
+
`decorations={false}` writes `_MOTIF_WM_HINTS` — honoured by Mutter, KWin,
|
|
670
|
+
Xfwm, Openbox and i3. A window manager that ignores it simply decorates the
|
|
671
|
+
window; there is no way to force the matter.
|
|
672
|
+
|
|
673
|
+
### Closing a window
|
|
674
|
+
|
|
675
|
+
Every window the window manager manages advertises `WM_DELETE_WINDOW` in
|
|
676
|
+
`WM_PROTOCOLS`, whether or not you pass `onCloseRequest`. That property is
|
|
677
|
+
what lets the WM _ask_ a window to close; a client without it can only be
|
|
678
|
+
shot, so the close button becomes `XKillClient` — the connection dies
|
|
679
|
+
mid-frame, effects never clean up, and IceWM asks the user to confirm the
|
|
680
|
+
kill first. Advertising it is not an opt-in feature, it is the difference
|
|
681
|
+
between closing and crashing, so react-x11 does it for you.
|
|
682
|
+
|
|
683
|
+
Windows the WM does not frame never advertise it, because nothing would read
|
|
684
|
+
it: a child `<window>` (a region inside another window) and an
|
|
685
|
+
override-redirect `<popup>`. A [managed `<popup>`](#a-managed-popup-is-a-dialog)
|
|
686
|
+
is a real dialog and does.
|
|
687
|
+
|
|
688
|
+
What a close request _does_ is the part you can change:
|
|
689
|
+
|
|
690
|
+
| | |
|
|
691
|
+
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
692
|
+
| no `onCloseRequest` | the **primary window** unmounts the tree and closes the connection — effects clean up and the process ends on a drained loop. Any other window refuses, and warns in dev. |
|
|
693
|
+
| `onCloseRequest` | nothing happens except your handler: `setOpen(false)` for a dialog, a "save your work?" prompt, `root.unmount()` for a quit of your own. |
|
|
694
|
+
|
|
695
|
+
The primary window is inferred, not declared: the first top-level `<window>`
|
|
696
|
+
that is not `transientFor` another and has no `windowType` of its own. A
|
|
697
|
+
one-window app — nearly every app — is unambiguous, and a lone window is the
|
|
698
|
+
app whatever type it declares.
|
|
699
|
+
|
|
700
|
+
The refusal for secondary windows is deliberate. A `{open && <window/>}` was
|
|
701
|
+
opened by a `setOpen(true)` somewhere, and closing it behind React's back
|
|
702
|
+
would leave a window the app still believes is open and can never reopen. So
|
|
703
|
+
a dialog wants a handler:
|
|
704
|
+
|
|
705
|
+
```jsx
|
|
706
|
+
{
|
|
707
|
+
showSettings && (
|
|
708
|
+
<window
|
|
709
|
+
title="Settings"
|
|
710
|
+
transientFor={main}
|
|
711
|
+
onCloseRequest={() => setShowSettings(false)}
|
|
712
|
+
/>
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
### Window state
|
|
718
|
+
|
|
719
|
+
`states` is EWMH `_NET_WM_STATE`: `modal`, `sticky`, `maximized` (or
|
|
720
|
+
`maximized_vert`/`maximized_horz` individually), `shaded`, `skip_taskbar`,
|
|
721
|
+
`skip_pager`, `hidden`, `fullscreen`, `above`, `below`, `demands_attention`,
|
|
722
|
+
`focused`. `fullscreen` and `alwaysOnTop` are boolean sugar for the two
|
|
723
|
+
everyone reaches for — `fullscreen` and `above` — and they union with
|
|
724
|
+
`states` rather than competing with it. On the [cocoa backend](macos.md)
|
|
725
|
+
`fullscreen` is the green button's full-screen Space, `maximized` the zoom,
|
|
726
|
+
`hidden` the Dock, and `demands_attention` bounces the Dock icon until the app
|
|
727
|
+
is activated, stopping when the state is removed ([desktop.md](desktop.md));
|
|
728
|
+
the rest have no AppKit equivalent and are inert there.
|
|
729
|
+
|
|
730
|
+
```jsx
|
|
731
|
+
<window
|
|
732
|
+
states={['skip_taskbar']}
|
|
733
|
+
fullscreen={isFullscreen}
|
|
734
|
+
onStatesChange={(states) => setFullscreen(states.includes('fullscreen'))}
|
|
735
|
+
/>
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
**These are controlled props, and the reason is not React convention.** On X
|
|
739
|
+
the window manager changes state behind the app's back constantly: the user
|
|
740
|
+
hits maximize, a global hotkey leaves fullscreen, a tiling WM has its own
|
|
741
|
+
opinion. react-x11 therefore diffs `states` against the _previous props_,
|
|
742
|
+
never against what the window currently has — so a prop is re-sent only when
|
|
743
|
+
the app changes its mind, and a WM that disagreed is not fought on the next
|
|
744
|
+
commit. `onStatesChange` is the other half: it reports what the WM actually
|
|
745
|
+
did, and subscribing to it is what makes react-x11 watch the property, so a
|
|
746
|
+
window without a handler costs nothing.
|
|
747
|
+
|
|
748
|
+
States are applied **before the window is mapped**, which is what EWMH 7.7
|
|
749
|
+
requires and the only way to open already fullscreen instead of flashing at
|
|
750
|
+
the normal size first.
|
|
751
|
+
|
|
752
|
+
`alwaysOnTop` falls back to Apple-WM window levels on XQuartz, where
|
|
753
|
+
quartz-wm does not support `_NET_WM_STATE_ABOVE`.
|
|
754
|
+
|
|
755
|
+
### `onClientMessage` — speaking a protocol of your own
|
|
756
|
+
|
|
757
|
+
Nearly every convention layered over X11 is carried by ClientMessage: EWMH's
|
|
758
|
+
requests to the window manager, XEmbed, the system tray, and whatever two
|
|
759
|
+
copies of one application agree between themselves. `onClientMessage` is
|
|
760
|
+
every one of them that was addressed to this window.
|
|
761
|
+
|
|
762
|
+
```jsx
|
|
763
|
+
<window
|
|
764
|
+
onClientMessage={(ev) => {
|
|
765
|
+
if (ev.messageType !== '_NET_SYSTEM_TRAY_OPCODE') return;
|
|
766
|
+
if (ev.data[1] === SYSTEM_TRAY_REQUEST_DOCK) dock(ev.data[2]);
|
|
767
|
+
}}
|
|
768
|
+
/>
|
|
769
|
+
```
|
|
770
|
+
|
|
771
|
+
`ev.messageType` is the atom's **name**, which is the point: without it a
|
|
772
|
+
handler has to intern its atoms first and compare numbers, and cannot say
|
|
773
|
+
anything at all until those round trips have landed. `ev.atom` is the id it
|
|
774
|
+
came in as, `ev.format` is 8, 16 or 32, and `ev.data` is the payload at that
|
|
775
|
+
width — 5 values at 32, 10 at 16, 20 at 8.
|
|
776
|
+
|
|
777
|
+
Nothing has to be armed. A ClientMessage reaches its window's owner whatever
|
|
778
|
+
event mask that window selected, so a `<window>` without the prop pays
|
|
779
|
+
nothing and one with it needs no other setup.
|
|
780
|
+
|
|
781
|
+
Three things worth knowing:
|
|
782
|
+
|
|
783
|
+
- **Messages arrive in the order they were sent.** The chunked protocols
|
|
784
|
+
depend on it — `_NET_SYSTEM_TRAY_BEGIN_MESSAGE` and the
|
|
785
|
+
`_NET_SYSTEM_TRAY_MESSAGE_DATA` pieces after it reassemble by arrival order
|
|
786
|
+
and nothing else. Naming a type this connection has never seen costs one
|
|
787
|
+
`GetAtomName`, and everything behind it waits rather than overtaking it.
|
|
788
|
+
`messageType` is therefore `null` only for an atom the **server** does not
|
|
789
|
+
know, which is a broken sender.
|
|
790
|
+
- **It is scoped to the window the message names**, not to the connection —
|
|
791
|
+
that is the whole difference from `useApp().X.on('event')`, which sees
|
|
792
|
+
every event for every window and outlives the element. A message aimed at
|
|
793
|
+
another window, including the EWMH ones sent to the root, does not arrive
|
|
794
|
+
here; a window manager wants the raw stream for those
|
|
795
|
+
(`examples/wm-core.js`).
|
|
796
|
+
- **`preventDefault()` stops react-x11 acting on the message itself**, which
|
|
797
|
+
today means XDND — for a window answering the drag protocol on its own
|
|
798
|
+
terms. It does not cover the WM close button; `onCloseRequest` is that
|
|
799
|
+
seam.
|
|
800
|
+
|
|
801
|
+
Sending is `useApp().X.SendClientMessage(destination, aboutWindow, atom,
|
|
802
|
+
format, data, eventMask)`, or ntk's `window.sendClientMessage(name, data)`
|
|
803
|
+
which interns the atom for you. Note the two window arguments: EWMH messages
|
|
804
|
+
about a window are delivered to the **root**, and messages to another
|
|
805
|
+
application's window pass an event mask of `0`.
|
|
806
|
+
|
|
807
|
+
Selections are the other half of most of these protocols, and taking one
|
|
808
|
+
needs a timestamp rather than `CurrentTime`:
|
|
809
|
+
[`lastInputTime` and `serverTime`](clipboard.md#owning-a-selection-of-your-own).
|
|
810
|
+
|
|
811
|
+
### Kiosk
|
|
812
|
+
|
|
813
|
+
There is no `kiosk` prop, because it is not one thing — it is a window with
|
|
814
|
+
no decoration, no taskbar entry, nothing above it, and no pointer:
|
|
815
|
+
|
|
816
|
+
```jsx
|
|
817
|
+
<window
|
|
818
|
+
fullscreen
|
|
819
|
+
decorations={false}
|
|
820
|
+
states={['above', 'skip_taskbar', 'skip_pager']}
|
|
821
|
+
style={{ cursor: 'none', flexGrow: 1 }}
|
|
822
|
+
/>
|
|
823
|
+
```
|
|
824
|
+
|
|
825
|
+
`cursor: 'none'` hides the pointer outright (ntk ≥ 4.2.0). It is not the
|
|
826
|
+
same as leaving `cursor` unset, which inherits whatever the root window's
|
|
827
|
+
cursor is — see [styling.md](styling.md).
|
|
828
|
+
|
|
829
|
+
### `transparent` — rounded corners and translucency
|
|
830
|
+
|
|
831
|
+
`transparent` creates the window on a 32-bit ARGB visual, so it has a real
|
|
832
|
+
alpha channel: what the tree does not paint stays empty, and a compositor
|
|
833
|
+
blends it against whatever is behind. That is what makes rounded corners
|
|
834
|
+
possible, and it is the main reason to reach for it:
|
|
835
|
+
|
|
836
|
+
```jsx
|
|
837
|
+
<popup
|
|
838
|
+
transparent
|
|
839
|
+
grab
|
|
840
|
+
style={{ backgroundColor: 'rgba(24, 24, 30, 0.86)', borderRadius: 14 }}
|
|
841
|
+
>
|
|
842
|
+
…
|
|
843
|
+
</popup>
|
|
844
|
+
```
|
|
845
|
+
|
|
846
|
+
Two props do the work together. `transparent` gives the window somewhere to
|
|
847
|
+
put transparency; `borderRadius` in the window's own `style` rounds the
|
|
848
|
+
background painted into it, and the corners it gives up are the corners the
|
|
849
|
+
desktop then shows through. The edge is **antialiased**, because it is alpha
|
|
850
|
+
rather than the Shape extension's 1-bit mask — no `XShapeCombineMask`, no
|
|
851
|
+
jagged diagonal.
|
|
852
|
+
|
|
853
|
+
`backgroundColor` may be translucent, and leaving it unset gives a window
|
|
854
|
+
that is empty except for what the tree paints.
|
|
855
|
+
|
|
856
|
+
`borderRadius` on a window is meaningful **only** with `transparent`. On an
|
|
857
|
+
opaque window it is ignored: giving up the corners there would only expose
|
|
858
|
+
the server's white, which is worse than square.
|
|
859
|
+
|
|
860
|
+
### When transparency is not available
|
|
861
|
+
|
|
862
|
+
**On the Cocoa backend it always is** — every window composites and every
|
|
863
|
+
window has an alpha channel, so `transparent` and a `borderRadius` on a
|
|
864
|
+
window simply work, and the rest of this section is about X11.
|
|
865
|
+
|
|
866
|
+
Transparency there needs two things, and either can be missing: a
|
|
867
|
+
**depth-32 TrueColor visual** (XQuartz has none), and a **running
|
|
868
|
+
compositor** (Mutter, KWin, picom, …) to blend the alpha channel. Without a
|
|
869
|
+
compositor the X server shows the raw pixels, and a corner you painted away
|
|
870
|
+
is not transparent — it is **black**.
|
|
871
|
+
|
|
872
|
+
react-x11 never lets that happen. When transparency would not actually be
|
|
873
|
+
seen, the window is filled edge to edge and `borderRadius` on it is ignored:
|
|
874
|
+
you get the square opaque popup, not a black-cornered one. A translucent
|
|
875
|
+
`backgroundColor` is flattened rather than composited onto the last frame.
|
|
876
|
+
Nothing is required of the application for that floor to hold.
|
|
877
|
+
|
|
878
|
+
What the application _does_ control is the design on the other side of it,
|
|
879
|
+
through the `'@supports transparency'` style block:
|
|
880
|
+
|
|
881
|
+
```jsx
|
|
882
|
+
<popup
|
|
883
|
+
transparent
|
|
884
|
+
style={{
|
|
885
|
+
backgroundColor: '#1c1c22', // square and opaque, works everywhere
|
|
886
|
+
'@supports transparency': {
|
|
887
|
+
backgroundColor: 'rgba(24, 24, 30, 0.86)',
|
|
888
|
+
borderRadius: 14,
|
|
889
|
+
},
|
|
890
|
+
}}
|
|
891
|
+
/>
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
See [Capability queries](styling.md#capability-queries). The block is
|
|
895
|
+
answered per window and re-resolved live, so a compositor being switched on
|
|
896
|
+
mid-session turns the popup rounded without a remount — which is why
|
|
897
|
+
`transparent` still takes the 32-bit visual when nothing is compositing yet.
|
|
898
|
+
A visual is a `CreateWindow` field and cannot be changed afterwards; what
|
|
899
|
+
the window paints can.
|
|
900
|
+
|
|
901
|
+
For decisions that are not styling, `useSupports('transparency')` answers
|
|
902
|
+
the same question about the display, and can be asked before any window
|
|
903
|
+
exists:
|
|
904
|
+
|
|
905
|
+
```jsx
|
|
906
|
+
const canBlend = useSupports('transparency');
|
|
907
|
+
const margin = canBlend ? 26 : 0; // room for a client-drawn shadow
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
To **look at the fallback** on a display that composites perfectly well,
|
|
911
|
+
run with `REACT_X11_NO_TRANSPARENCY=1`: `transparent` is ignored, the window
|
|
912
|
+
is created on the ordinary visual, and both the style block and
|
|
913
|
+
`useSupports` answer false. Otherwise the only way to see the design you
|
|
914
|
+
wrote for XQuartz is to stop the compositor, which takes the rest of your
|
|
915
|
+
desktop with it. See [debugging.md](debugging.md).
|
|
916
|
+
|
|
917
|
+
Toggling the `transparent` prop itself on a mounted window does nothing
|
|
918
|
+
until it remounts (change its `key`) — again because the visual is fixed at
|
|
919
|
+
creation. Requires **ntk ≥ 6.6.0**.
|
|
920
|
+
|
|
921
|
+
Two things a transparent window does **not** change. Input still hits the
|
|
922
|
+
full rectangle — the corners are invisible, not click-through; use the Shape
|
|
923
|
+
extension's input region through the ntk ref if that matters. And child
|
|
924
|
+
`<window>`s are composited by the X server, not the compositor, so nesting a
|
|
925
|
+
transparent window inside another window blends against nothing.
|
|
926
|
+
|
|
927
|
+
## `<popup>`
|
|
928
|
+
|
|
929
|
+
An override-redirect top-level window at **screen coordinates** — the
|
|
930
|
+
window manager ignores it (no decorations, no focus stealing): menus,
|
|
931
|
+
tooltips, dropdowns. May appear anywhere in the JSX tree (its position in
|
|
932
|
+
the tree does not affect its position on screen); it is its own paint and
|
|
933
|
+
event root. Give it an `anchor` and it places itself against a node
|
|
934
|
+
([below](#anchor--a-popup-that-places-itself)); `x`/`y` are there for the
|
|
935
|
+
placements that are nobody's node — `ev.screenX`/`screenY`, the pointer on
|
|
936
|
+
the virtual screen, in the logical pixels `x`/`y` are themselves in. Same props as `<window>` — **including
|
|
937
|
+
[natural size](#natural-size)**, which is what a menu sized by its own rows
|
|
938
|
+
is — and conditional rendering controls its lifetime.
|
|
939
|
+
|
|
940
|
+
| prop | |
|
|
941
|
+
| ------------------ | -------------------------------------------------------------------------------------------------------------- |
|
|
942
|
+
| `anchor` | place against a node, a rect inside one, or a rect on the screen, at whatever size the content turns out to be |
|
|
943
|
+
| `grab` | hold a pointer grab while the popup is up — how menus behave on X (below) |
|
|
944
|
+
| `grabKeyboard` | take the keyboard while the popup is up — what a popover a tray click opened needs (below) |
|
|
945
|
+
| `transparent` | 32-bit ARGB visual: rounded corners and translucency ([above](#transparent--rounded-corners-and-translucency)) |
|
|
946
|
+
| `onDismiss` | a press landed outside the popup: close it |
|
|
947
|
+
| `trapFocus` | own a focus scope: a modal (see [events.md](events.md#focus-scopes-modals)) |
|
|
948
|
+
| `overrideRedirect` | `false` makes it a WM-managed window instead — a real dialog (below) |
|
|
949
|
+
| `dragPreview` | this popup is a drag preview following the pointer, never a drop target — [drag-and-drop.md](drag-and-drop.md) |
|
|
950
|
+
|
|
951
|
+
### `anchor` — a popup that places itself
|
|
952
|
+
|
|
953
|
+
```jsx
|
|
954
|
+
function Completions({ editorRef, caret, matches, onPick }) {
|
|
955
|
+
if (!matches.length) return null;
|
|
956
|
+
return (
|
|
957
|
+
<popup
|
|
958
|
+
anchor={{ to: editorRef, at: caret, placement: 'bottom' }}
|
|
959
|
+
maxHeight={220}
|
|
960
|
+
>
|
|
961
|
+
<box
|
|
962
|
+
style={{ overflow: 'scroll', padding: 4, backgroundColor: '$surface' }}
|
|
963
|
+
>
|
|
964
|
+
{matches.map((m) => (
|
|
965
|
+
<box key={m.label} onClick={() => onPick(m)} style={{ padding: 6 }}>
|
|
966
|
+
<text>{m.label}</text>
|
|
967
|
+
</box>
|
|
968
|
+
))}
|
|
969
|
+
</box>
|
|
970
|
+
</popup>
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
That is the whole completion list: **at the caret**, as wide as its widest
|
|
976
|
+
label, no taller than 220 and scrolling past that, flipped above the line
|
|
977
|
+
when it is near the bottom of the screen. Nothing in it measures a font,
|
|
978
|
+
states a size, or names a position — `caret` is a rect the editor already
|
|
979
|
+
knows, in its own coordinates.
|
|
980
|
+
|
|
981
|
+
`anchor` takes [`anchorRect`'s options](components.md#useanchorref--anchorrectnode-options)
|
|
982
|
+
— `placement`, `align`, `offset`, `alignOffset`, `alignTo`, `at` — plus `to`,
|
|
983
|
+
the node (or a ref to one) it hangs off. `x`/`y` are ignored while it is set.
|
|
984
|
+
|
|
985
|
+
**Why the popup and not the application.** A `<popup>` with no `width` is
|
|
986
|
+
sized from its content like any other window, and that size is settled
|
|
987
|
+
_inside_ `realize()` — after the content is measured, before `CreateWindow`.
|
|
988
|
+
Which side the popup flips to and how far it is pulled back from a screen
|
|
989
|
+
edge are both functions of that size, so the earliest anyone can work out the
|
|
990
|
+
position is the moment after the measurement — which is already too late for
|
|
991
|
+
React to have passed one in. Doing it here keeps the rule the natural size
|
|
992
|
+
established: the window is **born** the right size in the right place, rather
|
|
993
|
+
than mapped somewhere provisional and corrected a frame later.
|
|
994
|
+
|
|
995
|
+
Afterwards it keeps up with everything that can move either half — the
|
|
996
|
+
anchor's own layout, an ancestor of it scrolling, the owner window being
|
|
997
|
+
dragged by the window manager, its own content growing a row.
|
|
998
|
+
|
|
999
|
+
`at` is a rect **inside** the node, in the node's own coordinates:
|
|
1000
|
+
|
|
1001
|
+
```js
|
|
1002
|
+
const caret = { x: caretX, y: lineTop, width: 1, height: lineHeight };
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
Everything reads it — the side that flips, the edge that aligns, the gap
|
|
1006
|
+
`offset` leaves — so a popup at a caret behaves like one hung off a small
|
|
1007
|
+
widget that happens to be there. `width`/`height` may be left out, and
|
|
1008
|
+
`{x, y}` alone is a point.
|
|
1009
|
+
|
|
1010
|
+
A popup whose anchor is a ref that has not attached yet — one written
|
|
1011
|
+
_above_ its own trigger in the JSX — waits for it rather than opening in the
|
|
1012
|
+
corner of the screen. That is one frame, and it is the same rule as the one
|
|
1013
|
+
below: an anchored popup is on screen only while there is something for it
|
|
1014
|
+
to point at.
|
|
1015
|
+
|
|
1016
|
+
**When the anchor scrolls out of view the popup unmaps** and comes back,
|
|
1017
|
+
where it belongs — and holding its `grab` again, since X drops a pointer
|
|
1018
|
+
grab whose window stops being viewable — when the anchor does. A popup is a real X window rather
|
|
1019
|
+
than an element its ancestors clip, so a caret that has scrolled out of the
|
|
1020
|
+
editor leaves a list floating over a document it no longer points into —
|
|
1021
|
+
and there is no position that fixes that. Whether it should _close_ instead
|
|
1022
|
+
is React state and therefore the application's: do the placement yourself
|
|
1023
|
+
with [`useAnchorTracking`](components.md#useanchorref--anchorrectnode-options)
|
|
1024
|
+
and its `onOutOfView`.
|
|
1025
|
+
|
|
1026
|
+
A popup never receives the X input focus, but nodes inside it can hold the
|
|
1027
|
+
**owner window's** focus and receive keys — with `trapFocus` and `autoFocus`
|
|
1028
|
+
that is a modal dialog. See
|
|
1029
|
+
[Focus inside a `<popup>`](events.md#focus-inside-a-popup). A popup that has
|
|
1030
|
+
no owner window to receive keys for it takes them itself with
|
|
1031
|
+
`grabKeyboard` ([below](#a-popover-under-a-tray-item)).
|
|
1032
|
+
|
|
1033
|
+
**`grab` is what makes a menu dismissable.** Without it, a press that lands
|
|
1034
|
+
anywhere else — another application, the root window, or even this app's own
|
|
1035
|
+
**window frame**, which belongs to the window manager — never reaches the
|
|
1036
|
+
client, so the menu stays open behind whatever was clicked. With the grab,
|
|
1037
|
+
that press is redirected to the popup instead, arrives outside its bounds,
|
|
1038
|
+
and `onDismiss` fires. The client's own windows still receive their own
|
|
1039
|
+
presses (X owner-events), so submenus and the owner window keep working
|
|
1040
|
+
normally, and only the root popup of a menu needs the grab. Needs
|
|
1041
|
+
ntk ≥ 3.7.0; on older ntk the popup behaves as it did before.
|
|
1042
|
+
|
|
1043
|
+
`Select`, `ContextMenu` and `MenuBar` already do this.
|
|
1044
|
+
|
|
1045
|
+
### A popover under a tray item
|
|
1046
|
+
|
|
1047
|
+
A tray click has no node behind it: [`useTray`](desktop.md#the-tray)'s
|
|
1048
|
+
`onClick` reports the item's frame on the screen, where the platform knows
|
|
1049
|
+
it. `anchor` takes that as `rect`, in place of `to`, and places against it
|
|
1050
|
+
with the same flip, clamp and natural size — kept on the monitor the rect is
|
|
1051
|
+
on, and below the menu bar, since that is not usable area:
|
|
1052
|
+
|
|
1053
|
+
```jsx
|
|
1054
|
+
function MenuBarApp() {
|
|
1055
|
+
const [open, setOpen] = useState(null);
|
|
1056
|
+
const close = () => setOpen(null);
|
|
1057
|
+
useTray({ icon: 'waveform', onClick: setOpen });
|
|
1058
|
+
if (!open) return null;
|
|
1059
|
+
return (
|
|
1060
|
+
<popup
|
|
1061
|
+
anchor={{ rect: open, placement: 'bottom', align: 'center', offset: 6 }}
|
|
1062
|
+
grab
|
|
1063
|
+
grabKeyboard
|
|
1064
|
+
onDismiss={close}
|
|
1065
|
+
onBlur={close}
|
|
1066
|
+
>
|
|
1067
|
+
<Controls onClose={close} />
|
|
1068
|
+
</popup>
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
`rect` is logical screen pixels, the unit a popup's `x`/`y` are in, and
|
|
1074
|
+
`{ x, y }` alone is a point, which is all a freedesktop tray reports: its
|
|
1075
|
+
protocol has no item rect. Nothing moves the rect but a new one, and it is
|
|
1076
|
+
never out of view.
|
|
1077
|
+
|
|
1078
|
+
### Centred over something
|
|
1079
|
+
|
|
1080
|
+
`placement: 'center'` puts the popup **over** its anchor rather than beside
|
|
1081
|
+
it — the middle of one on the middle of the other, clamped into the monitor,
|
|
1082
|
+
with nothing to flip to. Over a window's rect it is a dialog, and that is
|
|
1083
|
+
what [`Dialog`](components.md#dialog) does: it takes its owner window's rect
|
|
1084
|
+
on the screen when it opens and hands it over as `rect`, so the popup is
|
|
1085
|
+
measured, centred at the size it measured and only then created.
|
|
1086
|
+
|
|
1087
|
+
```jsx
|
|
1088
|
+
<popup
|
|
1089
|
+
anchor={{ rect: ownerRect, placement: 'center' }}
|
|
1090
|
+
width={360}
|
|
1091
|
+
height="auto"
|
|
1092
|
+
>
|
|
1093
|
+
```
|
|
1094
|
+
|
|
1095
|
+
A centred popup is centred **once**. When its own size changes afterwards —
|
|
1096
|
+
its content growing a line — it keeps its top edge and its horizontal middle
|
|
1097
|
+
and grows from there, pulled back only as far as it takes to stay on the
|
|
1098
|
+
monitor: re-centring would move the top edge by half the growth, and with it
|
|
1099
|
+
whatever the user was reading or about to press. A managed one grows from
|
|
1100
|
+
wherever the window manager or the user has put it since, and is moved only
|
|
1101
|
+
to keep it on the monitor. A new `rect` (or the `to` node moving) centres it
|
|
1102
|
+
again.
|
|
1103
|
+
|
|
1104
|
+
**The monitor comes from the whole rect**, not from a corner of it. A
|
|
1105
|
+
menu-bar item's frame starts a few points _above_ the top of the display it
|
|
1106
|
+
is on, so its top-left corner is outside that display — and on a desk where
|
|
1107
|
+
a taller neighbour reaches down past that edge, inside a different one. The
|
|
1108
|
+
monitor the rect overlaps most is the one it is on; a rect that overlaps
|
|
1109
|
+
none takes the nearest.
|
|
1110
|
+
|
|
1111
|
+
**`grabKeyboard` is the other half.** A menu-bar app has no window of its
|
|
1112
|
+
own, so the keys a popup's content would normally get through its owner
|
|
1113
|
+
window arrive nowhere. With it the popup takes the keyboard while it is up:
|
|
1114
|
+
|
|
1115
|
+
| backend | what it is |
|
|
1116
|
+
| ------- | ---------------------------------------------------------------------------------------------- |
|
|
1117
|
+
| X11 | a keyboard grab, taken and dropped with the map, as `grab` is for the pointer |
|
|
1118
|
+
| macOS | a window AppKit can make key, at the popup level, shown activating the app — `NSPopover`'s way |
|
|
1119
|
+
| Wayland | nothing more: a grabbing popup has the keyboard already |
|
|
1120
|
+
|
|
1121
|
+
On macOS the window's kind is decided when the popup is created, so the prop
|
|
1122
|
+
does not change on a mounted popup. There the popover closes on `onBlur` —
|
|
1123
|
+
clicking any other app takes the key window away — and on X on `onDismiss`,
|
|
1124
|
+
the press outside the pointer grab reports. Written with both, it closes the
|
|
1125
|
+
same way on either.
|
|
1126
|
+
|
|
1127
|
+
**Shortcuts in the popover work, with nothing passed.** A binding belongs to
|
|
1128
|
+
the tree's top-level `<window>`, and in an app that has none it belongs to
|
|
1129
|
+
the root-level `<popup>` that took the keyboard — this one, which is where
|
|
1130
|
+
the keys are arriving anyway:
|
|
1131
|
+
|
|
1132
|
+
```jsx
|
|
1133
|
+
function Controls({ onClose, onToggle }) {
|
|
1134
|
+
useAccelerator([['space']], onToggle);
|
|
1135
|
+
useAccelerator([['Escape']], onClose);
|
|
1136
|
+
return <box style={{ padding: 12, gap: 8 }}>…</box>;
|
|
1137
|
+
}
|
|
1138
|
+
```
|
|
1139
|
+
|
|
1140
|
+
The same goes for everything else that resolves an owner window on its own:
|
|
1141
|
+
`useFileDialog`'s parent, the global menu, `useWindowState`. A binding written
|
|
1142
|
+
where there is neither a window nor a popup under it — above the `if (!open)`
|
|
1143
|
+
in the app above, say — hangs off nothing and never fires; development says so
|
|
1144
|
+
once, and [`scope`](events.md#accelerators) anchors one at a node you name.
|
|
1145
|
+
|
|
1146
|
+
### A managed `<popup>` is a dialog
|
|
1147
|
+
|
|
1148
|
+
`overrideRedirect` defaults to `true` and is what makes a menu a menu.
|
|
1149
|
+
Passing `false` gives up all of that on purpose and hands the window to the
|
|
1150
|
+
window manager: it is reparented into a frame, so it has a titlebar, can be
|
|
1151
|
+
moved, and has a WM close button (`onCloseRequest`). Together with
|
|
1152
|
+
`transientFor` that is a real dialog — above its owner, out of the taskbar
|
|
1153
|
+
and alt-tab list, minimising with the owner.
|
|
1154
|
+
|
|
1155
|
+
```jsx
|
|
1156
|
+
<popup overrideRedirect={false} transientFor={anchor} windowType="dialog"
|
|
1157
|
+
title="Preferences" trapFocus>
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
Turn `grab` off with it. A client-side pointer grab over a window the WM is
|
|
1161
|
+
trying to let the user drag swallows the press that would start the drag.
|
|
1162
|
+
That also means a press outside no longer dismisses it — which is correct: a
|
|
1163
|
+
real dialog does not close because you clicked elsewhere.
|
|
1164
|
+
|
|
1165
|
+
The [`Dialog`](components.md#dialog) component does exactly this, and takes
|
|
1166
|
+
`managed={false}` to go back to the override-redirect shape.
|
|
1167
|
+
|
|
1168
|
+
Defaults to `windowType="popup_menu"` — the least-wrong answer for a popup
|
|
1169
|
+
that declares nothing; pass `windowType` to say what yours is
|
|
1170
|
+
(`"tooltip"`, `"dropdown_menu"`, …). The built-in widgets do:
|
|
1171
|
+
`Select`'s sheet is `dropdown_menu`, `Tooltip` is
|
|
1172
|
+
`tooltip`. The hint is **additive** — override-
|
|
1173
|
+
redirect is what keeps the WM from moving or decorating the popup, and
|
|
1174
|
+
stays on. The EWMH spec asks for the type hint on override-redirect
|
|
1175
|
+
windows too, so compositing managers can give menus and tooltips
|
|
1176
|
+
consistent shadows and animations.
|
|
1177
|
+
|
|
1178
|
+
## `<box>`
|
|
1179
|
+
|
|
1180
|
+
The flex container. All layout + paint + interaction props above.
|
|
1181
|
+
**Ref**: the retained node — `abs` (`{x, y, width, height}` within the
|
|
1182
|
+
window, valid after layout).
|
|
1183
|
+
|
|
1184
|
+
With `overflow: 'scroll'` it is also the **scroll container** — see below.
|
|
1185
|
+
|
|
1186
|
+
## Scrolling: `overflow: 'scroll'` {#scrolling}
|
|
1187
|
+
|
|
1188
|
+
There is no scrolling _element_. A `<box>` — or a `<window>` — whose style
|
|
1189
|
+
says `overflow: 'scroll'` becomes a clipped viewport over its own
|
|
1190
|
+
overflowing children, on **both axes**:
|
|
1191
|
+
|
|
1192
|
+
```jsx
|
|
1193
|
+
<box style={{ overflow: 'scroll', flexGrow: 1 }}>{rows}</box>
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
Wheel events scroll it by default; a scrollbar thumb is drawn on each axis
|
|
1197
|
+
that overflows, and the thumb can be dragged. `overflow: 'hidden'` still
|
|
1198
|
+
means what it always did — clip and do not scroll.
|
|
1199
|
+
|
|
1200
|
+
Because the switch is a style and not an element, a pane can start and stop
|
|
1201
|
+
scrolling without remounting, which is what makes `overflow={dense ?
|
|
1202
|
+
'scroll' : 'visible'}` cheap: the node, its children and their state all
|
|
1203
|
+
survive the change. When a box stops being a scroll container its offset
|
|
1204
|
+
resets to 0, as it does in CSS.
|
|
1205
|
+
|
|
1206
|
+
**It is also a tab stop, and answers the keyboard**, whenever it has
|
|
1207
|
+
somewhere to scroll — which is what lets a pane of unfocusable content (a
|
|
1208
|
+
log, a long `<text>`, a rendered document) be read without a pointer at all:
|
|
1209
|
+
|
|
1210
|
+
| key | |
|
|
1211
|
+
| ------------------- | ------------------------------------ |
|
|
1212
|
+
| arrows | one wheel notch (48px) on that axis |
|
|
1213
|
+
| PageUp / PageDown | a viewport, keeping a sliver of it |
|
|
1214
|
+
| Space / Shift+Space | the same, for the hand already there |
|
|
1215
|
+
| Home / End | the top and the bottom |
|
|
1216
|
+
|
|
1217
|
+
One that fits its content is not a tab stop, answers no keys and takes no
|
|
1218
|
+
wheel: it is a box with a clip, and stopping on it would be a stop that does
|
|
1219
|
+
nothing. **The wheel chains past it** to the next scroll container out — the
|
|
1220
|
+
same thing a browser does — so declaring a pane scrollable never steals a
|
|
1221
|
+
gesture the window would have answered. The keys are a default action, so an
|
|
1222
|
+
`onKeyDown` of your own runs first and `preventDefault()` cancels them.
|
|
1223
|
+
|
|
1224
|
+
A pane that also has an `onClick` keeps Space for paging: paging is the
|
|
1225
|
+
pane's own default action and runs ahead of the keyboard activation Space
|
|
1226
|
+
would otherwise be, so such a pane pages with Space and activates with Enter
|
|
1227
|
+
([events.md](events.md#space-and-enter-are-a-click)). A focusable row _inside_
|
|
1228
|
+
a pane takes both keys and pages with neither — a default action runs on the
|
|
1229
|
+
focused node, and that is the row.
|
|
1230
|
+
|
|
1231
|
+
The bar belongs to the scroller, not to the content painted under it — the
|
|
1232
|
+
same rule a browser applies — so a press on the thumb never reaches the row
|
|
1233
|
+
behind it. Dragging keeps the grip where it was taken, so the thumb does not
|
|
1234
|
+
jump to the pointer, and a press on the track pages towards it, like
|
|
1235
|
+
PageUp/PageDown. A drag moves the pane by whole device pixels, as a wheel
|
|
1236
|
+
does ([extending.md](extending.md#scrolling-content-you-painted)), so the
|
|
1237
|
+
scroll stays a blit and nothing in the pane is drawn off the pixel grid.
|
|
1238
|
+
`<textarea>` behaves the same way, and there a bar press never moves the
|
|
1239
|
+
caret.
|
|
1240
|
+
|
|
1241
|
+
### The layout defaults it brings
|
|
1242
|
+
|
|
1243
|
+
`overflow: 'scroll'` folds three CSS idioms into the resolved style, so a
|
|
1244
|
+
scroll container does not have to restate them:
|
|
1245
|
+
|
|
1246
|
+
| default | when |
|
|
1247
|
+
| ------------------------------ | ---------------------------------------- |
|
|
1248
|
+
| `flexBasis: 0` | `flexGrow > 0` and no `width` / `height` |
|
|
1249
|
+
| `minWidth: 0` / `minHeight: 0` | always |
|
|
1250
|
+
|
|
1251
|
+
`min-*: 0` is the CSS spec's own rule — `min-*: auto` computes to `0` on a
|
|
1252
|
+
flex item whose overflow is not `visible` — and it is what lets a viewport be
|
|
1253
|
+
smaller than what is inside it, which is what scrolling is. The zero basis is
|
|
1254
|
+
half of what `flex: 1` means. Without them a header/scroll/footer window
|
|
1255
|
+
grows past its own bounds as rows are added and the footer is pushed out of
|
|
1256
|
+
view. Set either yourself to opt out; an explicit value always wins.
|
|
1257
|
+
|
|
1258
|
+
### Props
|
|
1259
|
+
|
|
1260
|
+
| prop | |
|
|
1261
|
+
| ------------------- | -------------------------------------------------------------------------------- |
|
|
1262
|
+
| `onScroll(ev)` | `{scrollX, scrollY, contentWidth, contentHeight, viewportWidth, viewportHeight}` |
|
|
1263
|
+
| `onViewport(ev)` | `{width, height, contentWidth, contentHeight}` whenever they change |
|
|
1264
|
+
| `scrollbar={false}` | hide the drawn scrollbars |
|
|
1265
|
+
| `scrollbarColor` | thumb color |
|
|
1266
|
+
|
|
1267
|
+
**Ref**: the node, plus `scrollTo` / `scrollBy` / `scrollIntoView(node)` and
|
|
1268
|
+
`scrollX` / `scrollY` / `contentWidth` / `contentHeight`. In TypeScript,
|
|
1269
|
+
`useRef<ScrollableNode>(null)` types those in; `DrawnNode` still works for a
|
|
1270
|
+
box you do not scroll.
|
|
1271
|
+
|
|
1272
|
+
`scrollTo(y)` takes a number for the vertical axis; `scrollTo({x, y})` moves
|
|
1273
|
+
either, leaving alone whichever you omit. `scrollBy` matches.
|
|
1274
|
+
`scrollIntoView(node)` scrolls the minimum amount on both axes, and a node
|
|
1275
|
+
larger than the viewport that already covers it not at all.
|
|
1276
|
+
`canScroll(dx, dy)` answers whether there is room to move on the axis a delta
|
|
1277
|
+
names — what the wheel asks each node on its way out, and the one method an
|
|
1278
|
+
element of your own implements to be asked it too
|
|
1279
|
+
([extending.md](extending.md#scrolling-content-you-painted)).
|
|
1280
|
+
|
|
1281
|
+
Horizontal content comes from children that will not shrink — a row of
|
|
1282
|
+
fixed-width cells, say. The extent is measured **through the subtree**, the
|
|
1283
|
+
way `scrollWidth` is in a browser: a row that stretches to the viewport
|
|
1284
|
+
while its own cells overflow it still reports something to scroll. Anything
|
|
1285
|
+
that clips its own children ends that measurement, since their overflow
|
|
1286
|
+
belongs to them.
|
|
1287
|
+
|
|
1288
|
+
A device with a horizontal axis scrolls sideways with it; **Shift + vertical
|
|
1289
|
+
wheel** does too, for the mice and touchpads that have none. When both bars
|
|
1290
|
+
show, each stops short of the other's corner.
|
|
1291
|
+
|
|
1292
|
+
The distance is the device's, not a constant: where the server has XI2 a
|
|
1293
|
+
touchpad's scroll arrives as the fraction of a notch it measured, so a pane
|
|
1294
|
+
moves by a few pixels rather than by 48 at a time. A wheel notch is still 48
|
|
1295
|
+
pixels, and so is an arrow key ([events.md](events.md#wheel)).
|
|
1296
|
+
|
|
1297
|
+
`onViewport` fires from **layout**, not from scrolling, so it arrives for a
|
|
1298
|
+
list nobody has scrolled yet. That is what a virtualized list needs before
|
|
1299
|
+
it can decide how many rows are worth building: layout runs on the frame
|
|
1300
|
+
clock, after the commit that mounted the node, so an effect cannot read the
|
|
1301
|
+
size off the ref. `Table` is built on it. `onLayout`
|
|
1302
|
+
([Interaction props](#interaction-props)) is the same signal on any element;
|
|
1303
|
+
`onViewport` adds the content size a scroller measures.
|
|
1304
|
+
|
|
1305
|
+
`scrollIntoView(node)` scrolls the minimum amount that makes a descendant
|
|
1306
|
+
node fully visible — for one larger than the viewport, CSSOM View's
|
|
1307
|
+
`block: nearest`: left where it covers the viewport, and otherwise brought
|
|
1308
|
+
in by the edge that moves it least — and is safe to call from an effect right after that
|
|
1309
|
+
node mounts: the request is resolved on the next layout pass, when the
|
|
1310
|
+
node actually has geometry. Asking repaints nothing by itself. A request
|
|
1311
|
+
that finds the node in view already leaves the pane as it was, so focus
|
|
1312
|
+
moving between the fields of a form in a pane costs what it costs outside
|
|
1313
|
+
one: the two focus rings. One that does move the pane is a scroll like any
|
|
1314
|
+
other: where the backend can copy pixels, what stays on screen is copied
|
|
1315
|
+
and only the strip the move uncovers is repainted.
|
|
1316
|
+
|
|
1317
|
+
`scrollTo` answers inside the call, from the extent the last layout pass
|
|
1318
|
+
measured: the offset moves and `onScroll` fires before it returns. When a
|
|
1319
|
+
pass is already owed, the part of the request that extent could not answer
|
|
1320
|
+
is kept as well, and the pass answers it again against the content it
|
|
1321
|
+
measures, before the frame paints. Two cases need that:
|
|
1322
|
+
|
|
1323
|
+
- A pane no pass has laid out yet has no extent at all. Restoring a list's
|
|
1324
|
+
position from a mount-time `useLayoutEffect` shows the list at that
|
|
1325
|
+
position in its first frame.
|
|
1326
|
+
- Rows added in the commit the request comes from are not in the extent
|
|
1327
|
+
yet. A log that follows its new lines with `scrollTo(Infinity)` from a
|
|
1328
|
+
layout effect shows each one at the end, in the first frame that has it.
|
|
1329
|
+
|
|
1330
|
+
`Infinity` means the end, wherever the pass finds it, and the End key goes
|
|
1331
|
+
there the same way. Prefer it to `contentHeight`, which is the last pass's
|
|
1332
|
+
measurement too: a target computed from it falls short when a commit adds
|
|
1333
|
+
more than a viewport. A `scrollBy` made before that pass moves on from where
|
|
1334
|
+
the pass puts the pane. Until the pass, `scrollX`/`scrollY` read the call's
|
|
1335
|
+
answer, and `onScroll` reports the pass's once it is over.
|
|
1336
|
+
|
|
1337
|
+
`onScroll` fires for **every** move of the offsets, not only the ones a call
|
|
1338
|
+
made. The wheel, the keys, a bar and `scrollTo`/`scrollBy` on a laid-out
|
|
1339
|
+
pane report inside the call. Layout moves the offsets on its own, and a
|
|
1340
|
+
browser fires `scroll` for each: the held `scrollTo` above landing, a
|
|
1341
|
+
`scrollIntoView` resolving (focus landing on a row below the fold is one),
|
|
1342
|
+
and the clamp that pulls the offset back when the content shrinks or the
|
|
1343
|
+
viewport grows under it, as when rows are filtered out of a list scrolled
|
|
1344
|
+
to its end. Those report once the pass is over, like `onViewport`: after
|
|
1345
|
+
the frame that shows the new offset, and only if the pass moved it. The
|
|
1346
|
+
payload is read when the report is delivered, so a handler that mirrors the
|
|
1347
|
+
offset into state, as `Table` does, ends where the pane did.
|
|
1348
|
+
|
|
1349
|
+
### On a `<window>`
|
|
1350
|
+
|
|
1351
|
+
`<window style={{ overflow: 'scroll' }}>` scrolls the window's own content,
|
|
1352
|
+
with no inner pane at all — the same wheel, keys and bars. The window stays
|
|
1353
|
+
a `frame` to a screen reader whatever its overflow says. Its ref is still
|
|
1354
|
+
ntk's `Window`, so reach the scroll API through a ref on a box inside it, or
|
|
1355
|
+
through the node in `onViewport`.
|
|
1356
|
+
|
|
1357
|
+
## `<text>`
|
|
1358
|
+
|
|
1359
|
+
Shaped, wrapped text through ntk's text stack (bidi, ligatures, kerning,
|
|
1360
|
+
font fallback). Strings/numbers are only legal inside `<text>`; nested
|
|
1361
|
+
`<text>` elements are **style spans** — the paragraph is laid out as one
|
|
1362
|
+
run list so wrapping spans the whole content.
|
|
1363
|
+
|
|
1364
|
+
| prop | |
|
|
1365
|
+
| --------------------------------------------------- | ------------------------------------------------------ |
|
|
1366
|
+
| `color` | text color — inherited (see below) |
|
|
1367
|
+
| `fontSize`, `fontFamily`, `fontWeight`, `fontStyle` | ntk font style (fontconfig lookup + fallback) |
|
|
1368
|
+
| `fontVariationSettings` | a variable font's axes — see below |
|
|
1369
|
+
| `textRendering` | which glyph path to draw with — see below |
|
|
1370
|
+
| `letterSpacing` | space after every character, in px — see below |
|
|
1371
|
+
| `fontVariantNumeric`, `fontFeatureSettings` | figures and OpenType features — see below |
|
|
1372
|
+
| `textAlign` | `left`, `right`, `center`, `start`, `end` (bidi-aware) |
|
|
1373
|
+
| `lineHeight` | multiplier over the natural font line height |
|
|
1374
|
+
| `textWrap` | `wrap` (default) or `nowrap` |
|
|
1375
|
+
| `maxLines` | how many lines are kept — unlimited by default |
|
|
1376
|
+
| `textOverflow` | `clip` (default) or `ellipsis`, for what was cut |
|
|
1377
|
+
|
|
1378
|
+
The first four rows, `fontVariationSettings`, `textRendering`,
|
|
1379
|
+
`letterSpacing` and the two feature properties **inherit** — from a nested
|
|
1380
|
+
span's point of view that has always been true, and it is equally true
|
|
1381
|
+
across a `<box>`, so a caption block is written once around the labels in it
|
|
1382
|
+
([styling.md](styling.md#inheritance-the-ink-the-face-and-the-size)).
|
|
1383
|
+
The rest do not: they shape the box the lines flow in, which belongs to the
|
|
1384
|
+
`<text>` that owns it.
|
|
1385
|
+
|
|
1386
|
+
`maxLines` and `textOverflow` are how a label says it did not fit —
|
|
1387
|
+
`{ textWrap: 'nowrap', textOverflow: 'ellipsis' }` is one line ending in a
|
|
1388
|
+
`…` at the pixel the box ends at, and an ellipsis with no `maxLines` means
|
|
1389
|
+
one line. The cut is a fact about the pixels only: the accessible name, the
|
|
1390
|
+
caret indices and `textContent()` are all still the whole string. See
|
|
1391
|
+
[styling.md](styling.md#keeping-text-on-one-line-and-saying-when-it-did-not-fit).
|
|
1392
|
+
|
|
1393
|
+
A face the app supplies itself — one it ships, or one the user picked in a
|
|
1394
|
+
dialog — is opened with `loadFont`/`useFont`, which hand back the family
|
|
1395
|
+
name to put in `fontFamily`
|
|
1396
|
+
([styling.md](styling.md#a-font-file-of-your-own)).
|
|
1397
|
+
|
|
1398
|
+
`start` and `end` are resolved against the box's own **direction**, not
|
|
1399
|
+
against the first strong character in the string — the box says which way it
|
|
1400
|
+
reads and the paragraph is laid out at that base level, so `"(12) files"` in
|
|
1401
|
+
an RTL panel punctuates as an RTL sentence and a `<text>` with no strong
|
|
1402
|
+
characters at all still lands on the right side.
|
|
1403
|
+
See [styling.md](styling.md#direction-and-the-logical-edges).
|
|
1404
|
+
|
|
1405
|
+
### Variable fonts
|
|
1406
|
+
|
|
1407
|
+
_ntk ≥ 7.1.0._
|
|
1408
|
+
|
|
1409
|
+
A variable font is one file with a continuous design space, and
|
|
1410
|
+
`fontWeight` already drives its `wght` axis — so a family with one variable
|
|
1411
|
+
file behaves like a family with nine faces, and like nine hundred, since
|
|
1412
|
+
the weights between the named instances are the point of an axis:
|
|
1413
|
+
|
|
1414
|
+
```jsx
|
|
1415
|
+
<text style={{ fontFamily: 'Inter', fontWeight: 460 }}>
|
|
1416
|
+
Serious by default.
|
|
1417
|
+
</text>
|
|
1418
|
+
```
|
|
1419
|
+
|
|
1420
|
+
`fontVariationSettings` is for the other axes, by OpenType tag:
|
|
1421
|
+
|
|
1422
|
+
```jsx
|
|
1423
|
+
<text style={{ fontVariationSettings: { wdth: 87.5, slnt: -8 } }}>
|
|
1424
|
+
condensed
|
|
1425
|
+
</text>
|
|
1426
|
+
```
|
|
1427
|
+
|
|
1428
|
+
It inherits into spans like any other text style prop, and a span may
|
|
1429
|
+
override it. Axes the font does not have are ignored and values are clamped
|
|
1430
|
+
to each axis's range, so it is safe to set without checking the face first.
|
|
1431
|
+
It is compared **by value**, so the object literal above is fine — a fresh
|
|
1432
|
+
one with the same numbers on the next render costs nothing.
|
|
1433
|
+
|
|
1434
|
+
ntk instantiates on demand and rasterizes only the glyphs actually drawn,
|
|
1435
|
+
at the size drawn; both its caches are bounded. What it does not do is
|
|
1436
|
+
quantize for you, so a slider bound straight to an axis with `step={1}`
|
|
1437
|
+
really will ask for hundreds of distinct faces as it is dragged — step the
|
|
1438
|
+
control, not the font. See ntk's `docs/fonts.md` for the whole picture,
|
|
1439
|
+
including why a variable `.woff2` cannot be instantiated and you want the
|
|
1440
|
+
`.ttf`.
|
|
1441
|
+
|
|
1442
|
+
### `textRendering`
|
|
1443
|
+
|
|
1444
|
+
_ntk ≥ 7.2.0._ CSS's property, picking which glyph path draws the text.
|
|
1445
|
+
|
|
1446
|
+
| value | |
|
|
1447
|
+
| -------------------- | --------------------------------------------------------- |
|
|
1448
|
+
| `auto` (default) | ntk decides from the size — right for UI text |
|
|
1449
|
+
| `geometricPrecision` | glyph origins exactly where shaping asked; nothing cached |
|
|
1450
|
+
| `optimizeSpeed` | ntk's cached glyph bitmaps, at any size |
|
|
1451
|
+
| `optimizeLegibility` | accepted, means `auto` — there is no hinting to turn on |
|
|
1452
|
+
|
|
1453
|
+
```jsx
|
|
1454
|
+
<text style={{ fontSize: 96, textRendering: 'geometricPrecision' }}>
|
|
1455
|
+
Serious by default.
|
|
1456
|
+
</text>
|
|
1457
|
+
```
|
|
1458
|
+
|
|
1459
|
+
**Why display text wants it.** On the cached path a glyph's advance is baked
|
|
1460
|
+
into the server-side glyphset as a whole number, because a cached bitmap can
|
|
1461
|
+
only land on a whole pixel. Animate a variable font's `wght` and the true
|
|
1462
|
+
advances move by hundredths of a pixel — hundredths that accumulate along
|
|
1463
|
+
the line until one glyph crosses a rounding boundary and jumps a whole pixel
|
|
1464
|
+
on its own while its neighbours stand still. `geometricPrecision` puts every
|
|
1465
|
+
glyph where shaping asked, so the line slides instead of stepping.
|
|
1466
|
+
|
|
1467
|
+
It inherits into spans and a span may override it, so a headline and the
|
|
1468
|
+
body text under it can take different paths in one paragraph.
|
|
1469
|
+
|
|
1470
|
+
**It never reflows.** Only draw-time rounding differs; ntk's layout measures
|
|
1471
|
+
byte-identically for every value, down to per-run offsets. Changing it drops
|
|
1472
|
+
the cached layout — the value rides on the spans inside it — and repaints,
|
|
1473
|
+
without marking anything dirty for yoga. So it is safe to flip on a state
|
|
1474
|
+
change without the tree moving.
|
|
1475
|
+
|
|
1476
|
+
The cost is real: the precise path rasterizes outlines on every draw and
|
|
1477
|
+
caches nothing. That is the right trade for text being animated and the
|
|
1478
|
+
wrong one for a paragraph that never changes, which is why `auto` is the
|
|
1479
|
+
default rather than the other way round.
|
|
1480
|
+
|
|
1481
|
+
### Letter spacing and figures
|
|
1482
|
+
|
|
1483
|
+
_ntk ≥ 8.9.0 on X11, `@windowkit/appkit` ≥ 0.11.0 on Cocoa._
|
|
1484
|
+
|
|
1485
|
+
`fontVariantNumeric: 'tabular-nums'` gives every digit one width. Most UI
|
|
1486
|
+
faces — SF Pro, Inter — draw proportional figures by default, where a `1` is
|
|
1487
|
+
much narrower than an `8`, so a number that changes while you watch it
|
|
1488
|
+
changes width too, and a readout beside a slider shuffles its row on every
|
|
1489
|
+
value. Tabular figures hold it still:
|
|
1490
|
+
|
|
1491
|
+
```jsx
|
|
1492
|
+
<text style={{ fontSize: 30, fontVariantNumeric: 'tabular-nums' }}>
|
|
1493
|
+
{hz} Hz
|
|
1494
|
+
</text>
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
The keywords are CSS's: `lining-nums` or `oldstyle-nums`, `proportional-nums`
|
|
1498
|
+
or `tabular-nums`, `diagonal-fractions` or `stacked-fractions`, `ordinal` and
|
|
1499
|
+
`slashed-zero` — several at once, at most one from each pair, as in
|
|
1500
|
+
`'tabular-nums slashed-zero'`. `'normal'` puts the face's own figures back.
|
|
1501
|
+
|
|
1502
|
+
`fontFeatureSettings` is any OpenType feature, by its four-letter tag: an
|
|
1503
|
+
array of the ones to turn on, or an object of tag to on, off, or the
|
|
1504
|
+
alternate a feature picks.
|
|
1505
|
+
|
|
1506
|
+
```jsx
|
|
1507
|
+
<text style={{ fontFeatureSettings: ['tnum', 'zero'] }}>0.10</text>
|
|
1508
|
+
<text style={{ fontFeatureSettings: { ss01: true, salt: 2 } }}>Rag</text>
|
|
1509
|
+
```
|
|
1510
|
+
|
|
1511
|
+
Where the two name the same feature, `fontFeatureSettings` wins. A feature
|
|
1512
|
+
the face does not have is ignored, and the settings are compared by value,
|
|
1513
|
+
so an object literal is fine.
|
|
1514
|
+
|
|
1515
|
+
`letterSpacing` is CSS's `letter-spacing`, in logical pixels: added after
|
|
1516
|
+
every character, the last on a line included, and negative to tighten.
|
|
1517
|
+
|
|
1518
|
+
```jsx
|
|
1519
|
+
<text style={{ fontSize: 11, fontWeight: 600, letterSpacing: 1.26 }}>
|
|
1520
|
+
NOISE TYPE
|
|
1521
|
+
</text>
|
|
1522
|
+
```
|
|
1523
|
+
|
|
1524
|
+
Spaced text drops the optional ligatures — `liga`, `clig`, `dlig` and
|
|
1525
|
+
`hlig` — because a ligature is one glyph, with no gap in its middle to open.
|
|
1526
|
+
Name one in `fontFeatureSettings` to keep it anyway.
|
|
1527
|
+
|
|
1528
|
+
All three inherit, and the two feature properties inherit apart, as CSS
|
|
1529
|
+
inherits them: a `fontVariantNumeric` below replaces the one above and
|
|
1530
|
+
leaves an inherited `fontFeatureSettings` alone. All three can move glyphs,
|
|
1531
|
+
so a change re-measures, and none of them may go in a state block.
|
|
1532
|
+
|
|
1533
|
+
### Tiny text
|
|
1534
|
+
|
|
1535
|
+
Text smaller than **6 logical pixels** is painted as a strip of its ink over
|
|
1536
|
+
the band its letters sit in, one rectangle per line, instead of as glyphs. A
|
|
1537
|
+
zoomed-out view — a minimap, a graph at a tenth of its size, a grid of five
|
|
1538
|
+
thousand cells — is a screen of labels nobody can read, each of which would
|
|
1539
|
+
cost a glyph run at the price of a legible one; below a legible size a label
|
|
1540
|
+
is a smudge of its ink, and the strip is that smudge for one fill. Layout is
|
|
1541
|
+
untouched: the text is still shaped, the strip is as wide as the line. The
|
|
1542
|
+
size is logical because legibility is physical — a 5px label is the same
|
|
1543
|
+
size on a 2x panel as on a 1x monitor.
|
|
1544
|
+
|
|
1545
|
+
`createRoot({ textStripBelow })` moves the line, in logical pixels; `0`
|
|
1546
|
+
keeps glyphs at every size. `REACT_X11_TEXT_STRIP_BELOW` is the same line
|
|
1547
|
+
for a process that cannot reach `createRoot`.
|
|
1548
|
+
|
|
1549
|
+
## Selecting text
|
|
1550
|
+
|
|
1551
|
+
_Issue #259._ A label is not selectable, the way `Gtk.Label` is not: a
|
|
1552
|
+
desktop application is full of text that is chrome rather than content, and
|
|
1553
|
+
a stray drag lighting up a button's caption is noise. Text becomes
|
|
1554
|
+
selectable when an element says so.
|
|
1555
|
+
|
|
1556
|
+
```jsx
|
|
1557
|
+
<box selectable style={{ padding: 12 }}>
|
|
1558
|
+
<text style={{ fontSize: 20, fontWeight: 'bold' }}>Release notes</text>
|
|
1559
|
+
<text>Everything below this heading can be dragged over and copied.</text>
|
|
1560
|
+
</box>
|
|
1561
|
+
```
|
|
1562
|
+
|
|
1563
|
+
That one prop is the whole feature. The element becomes a **surface**: a
|
|
1564
|
+
drag inside it selects across every piece of text under it, a double click
|
|
1565
|
+
takes a word, a triple click takes a block, Ctrl+A takes the surface and
|
|
1566
|
+
Ctrl+C copies it. Releasing the button hands the text to **PRIMARY**, so a
|
|
1567
|
+
middle click in a terminal pastes it — which is what selecting text means
|
|
1568
|
+
on X11 ([clipboard.md](clipboard.md)). macOS has no PRIMARY, so there a
|
|
1569
|
+
release copies nothing and only a copy reaches the pasteboard
|
|
1570
|
+
([clipboard.md](clipboard.md#primary-is-x11-only)).
|
|
1571
|
+
|
|
1572
|
+
### What is in a surface
|
|
1573
|
+
|
|
1574
|
+
Everything under it that can answer for its own text, in document order.
|
|
1575
|
+
`<text>` can; so can any element that implements the accessors in
|
|
1576
|
+
[extending.md](extending.md#answering-for-your-own-text) — a terminal, a log
|
|
1577
|
+
view, a code editor written outside this package are all selected across by
|
|
1578
|
+
the same drag, with no registration call.
|
|
1579
|
+
|
|
1580
|
+
What is **not** in it:
|
|
1581
|
+
|
|
1582
|
+
- anything under a `selectable={false}`, which is CSS's `user-select: none`.
|
|
1583
|
+
A list's bullets, a table's chrome, a button's caption inside a document;
|
|
1584
|
+
- `<textinput>` and `<textarea>`, and any element that says
|
|
1585
|
+
`hasOwnSelection` — they keep their own selection, and a press in one is
|
|
1586
|
+
theirs;
|
|
1587
|
+
- `<svg>`, and any registered element that draws its own text, which draw
|
|
1588
|
+
their text through an ntk widget that exposes no character geometry to us.
|
|
1589
|
+
|
|
1590
|
+
### What a copy assembles
|
|
1591
|
+
|
|
1592
|
+
The separators come from the **layout**, not from the markup: core cannot
|
|
1593
|
+
know that one `<text>` is a table cell and another is a paragraph, and
|
|
1594
|
+
asking every application to say so would be a second authoring model for
|
|
1595
|
+
something the screen already shows. Two pieces of text sharing a band of
|
|
1596
|
+
pixels are joined with a **tab**; one that starts below the last is joined
|
|
1597
|
+
with a **newline**. For a table laid out as rows of cells that is exactly
|
|
1598
|
+
"cells with tabs, rows with newlines", and for ordinary prose it is one
|
|
1599
|
+
paragraph per line.
|
|
1600
|
+
|
|
1601
|
+
```jsx
|
|
1602
|
+
// copies as "name\tsize\nnotes\t4 kB"
|
|
1603
|
+
<box selectable>
|
|
1604
|
+
<box style={{ flexDirection: 'row' }}>
|
|
1605
|
+
<text style={{ width: 120 }}>name</text>
|
|
1606
|
+
<text>size</text>
|
|
1607
|
+
</box>
|
|
1608
|
+
<box style={{ flexDirection: 'row' }}>
|
|
1609
|
+
<text style={{ width: 120 }}>notes</text>
|
|
1610
|
+
<text>4 kB</text>
|
|
1611
|
+
</box>
|
|
1612
|
+
</box>
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
A list marker excluded with `selectable={false}` is absent from the copied
|
|
1616
|
+
text as well as from the highlight, which is the point of excluding it.
|
|
1617
|
+
|
|
1618
|
+
### The props
|
|
1619
|
+
|
|
1620
|
+
| prop | |
|
|
1621
|
+
| ----------------------- | ------------------------------------------------------------------------------------------- |
|
|
1622
|
+
| `selectable` | `true` starts a surface; `false` opts a subtree out of the one above it |
|
|
1623
|
+
| `selectionColor` | the highlight behind selected text (default: the theme's `selection`, a tint of its accent) |
|
|
1624
|
+
| `onSelectionChange(ev)` | `ev.text` is what a copy would put on the clipboard, `ev.isCollapsed` whether it is empty |
|
|
1625
|
+
|
|
1626
|
+
And on the surface's node, through a ref: `selectAll()`, `clearSelection()`,
|
|
1627
|
+
`selectedText()`, `setSelection(anchor, focus)` — each end
|
|
1628
|
+
`{ node, index }`, indices in code points — and `textSelection`, a snapshot of
|
|
1629
|
+
`{ isCollapsed, text, ranges }`.
|
|
1630
|
+
|
|
1631
|
+
### One selection, and where the keys go
|
|
1632
|
+
|
|
1633
|
+
**Only one selection is visible in an application at a time.** Selecting in
|
|
1634
|
+
a document collapses the highlight in the field beside it, and selecting in
|
|
1635
|
+
a field clears the document — the two never both claim to be showing the
|
|
1636
|
+
selection, which is a rule core holds rather than one every surface has to
|
|
1637
|
+
keep. That is also what makes PRIMARY honest: there is one selection on the
|
|
1638
|
+
display, so there is one here.
|
|
1639
|
+
|
|
1640
|
+
A surface is a **focus target**, because Ctrl+A and Ctrl+C are keystrokes
|
|
1641
|
+
and a keystroke has to arrive somewhere. That puts it in the Tab cycle;
|
|
1642
|
+
`tabIndex={-1}` takes it back out while leaving it focusable by a press —
|
|
1643
|
+
which is the right choice for a document inside a larger UI, and the wrong
|
|
1644
|
+
one for a reader whose window is the document.
|
|
1645
|
+
|
|
1646
|
+
The pointer shows an I-beam over a surface, the way it does over a field.
|
|
1647
|
+
`style={{ cursor: … }}` overrides it as usual.
|
|
1648
|
+
|
|
1649
|
+
The selection is reported to assistive technology as it moves: a `<text>`
|
|
1650
|
+
already exposed AT-SPI's text interface, and the range a reader has dragged
|
|
1651
|
+
across now travels through it ([accessibility.md](accessibility.md#text-controls)).
|
|
1652
|
+
|
|
1653
|
+
### The geometry underneath
|
|
1654
|
+
|
|
1655
|
+
The selection is built out of four accessors that every drawn node answers,
|
|
1656
|
+
and they are public in their own right — a caret rect is what an
|
|
1657
|
+
autocomplete popup anchors to (`<popup anchor={{ to, at }}>`), and a hit
|
|
1658
|
+
test is how an app turns a click into a character offset:
|
|
1659
|
+
|
|
1660
|
+
```js
|
|
1661
|
+
const node = paragraphRef.current;
|
|
1662
|
+
node.textContent(); // 'Everything below this heading…'
|
|
1663
|
+
node.textIndexAt(ev.x, ev.y); // 14 — window coordinates in, a code point out
|
|
1664
|
+
node.textCaretRect(14); // { x, y, width: 0, height }
|
|
1665
|
+
node.textRangeRects(4, 14); // the bands a highlight over [4, 14) fills
|
|
1666
|
+
```
|
|
1667
|
+
|
|
1668
|
+
Indices are **code points**, not UTF-16 units, so an emoji is one position.
|
|
1669
|
+
Rectangles are in the owning window's coordinates — the same space as
|
|
1670
|
+
`abs` and `contentBox()`, which is device pixels; a mouse event's `x`/`y`
|
|
1671
|
+
are logical ([scale.md](scale.md)). `textRangeRects` returns
|
|
1672
|
+
one band per line _and_ one per direction run inside a line: a range that
|
|
1673
|
+
crosses from Latin into Arabic covers two separate stretches of pixels, and
|
|
1674
|
+
a single rectangle drawn between two caret positions would paint over text
|
|
1675
|
+
nobody selected.
|
|
1676
|
+
|
|
1677
|
+
---
|
|
1678
|
+
|
|
1679
|
+
## `<textinput>`
|
|
1680
|
+
|
|
1681
|
+

|
|
1682
|
+
|
|
1683
|
+
Single-line editable text. Caret and selection geometry come from ntk's
|
|
1684
|
+
`TextLayout.caretPosition`/`indexAt` (ntk ≥ 3.3.0), so positions are exact
|
|
1685
|
+
across kerning, shaping boundaries and trailing whitespace.
|
|
1686
|
+
|
|
1687
|
+
| prop | |
|
|
1688
|
+
| --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
1689
|
+
| `value` + `onChange(ev)` | controlled mode (display follows the prop) |
|
|
1690
|
+
| `defaultValue` | uncontrolled mode |
|
|
1691
|
+
| `onSubmit(ev)` | Enter or Shift+Enter; with Ctrl, Alt or Super held it is a shortcut's ([events.md](events.md#accelerators)) |
|
|
1692
|
+
| `name` | field name, echoed on the event |
|
|
1693
|
+
| `placeholder`, `placeholderColor` | shown when empty |
|
|
1694
|
+
| `maxLength` | code-point limit |
|
|
1695
|
+
| `selectionColor`, `caretColor` | selection/caret paint |
|
|
1696
|
+
| `inputMode` | what an input method should expect — the DOM's `numeric`, `decimal`, `tel`, `email`, `url` ([events.md](events.md#input-methods)) |
|
|
1697
|
+
| text style props | as `<text>` |
|
|
1698
|
+
|
|
1699
|
+
The text style props include `textAlign`, and the field is laid out at its
|
|
1700
|
+
box's **direction** the way a `<text>` is: the value, the placeholder and the
|
|
1701
|
+
caret sit at the base direction's start edge — the right-hand one under
|
|
1702
|
+
`direction: 'rtl'` — and the value is shaped at that base level rather than
|
|
1703
|
+
at its own first strong character, which is what makes `"(1) 12:30 — نص"`
|
|
1704
|
+
punctuate the same way in a field and in the paragraph beside it. Clicking,
|
|
1705
|
+
the caret, the selection bands and the horizontal scroll are read from one
|
|
1706
|
+
placement, so they mirror together. See
|
|
1707
|
+
[styling.md](styling.md#inside-an-editable-field).
|
|
1708
|
+
|
|
1709
|
+
### The change event
|
|
1710
|
+
|
|
1711
|
+
`onChange` and `onSubmit` get a synthetic event, the same shape every other
|
|
1712
|
+
handler in the library gets, rather than a bare string:
|
|
1713
|
+
|
|
1714
|
+
```jsx
|
|
1715
|
+
<textinput
|
|
1716
|
+
name="email"
|
|
1717
|
+
value={email}
|
|
1718
|
+
onChange={(ev) => setEmail(ev.target.value)}
|
|
1719
|
+
/>
|
|
1720
|
+
```
|
|
1721
|
+
|
|
1722
|
+
| on the event | |
|
|
1723
|
+
| ----------------------------------- | --------------------------------------------------- |
|
|
1724
|
+
| `ev.value`, `ev.target.value` | the text **after** the edit |
|
|
1725
|
+
| `ev.name`, `ev.target.name` | the `name` prop |
|
|
1726
|
+
| `ev.type` | `'change'` or `'submit'` |
|
|
1727
|
+
| `ev.target`, `ev.currentTarget` | the node (they are the same — this does not bubble) |
|
|
1728
|
+
| `ev.nativeEvent` | the X key event, or **null** — see below |
|
|
1729
|
+
| `preventDefault`, `stopPropagation` | present for uniformity; nothing reads them |
|
|
1730
|
+
|
|
1731
|
+
Two details worth knowing:
|
|
1732
|
+
|
|
1733
|
+
- **`ev.target.value` is the new value even in controlled mode**, where
|
|
1734
|
+
`props.value` is still the old string until the parent re-renders. That is
|
|
1735
|
+
what makes `e.target.value` mean here what it means in the DOM, and it is
|
|
1736
|
+
why react-hook-form and formik work — see
|
|
1737
|
+
[docs/ecosystem/forms.md](ecosystem/forms.md). It is true _while the
|
|
1738
|
+
handler runs_: `ev.target` is the live node, so a handler that stashes the
|
|
1739
|
+
event and reads `ev.target.value` later sees whatever the control holds
|
|
1740
|
+
then. `ev.value` is a snapshot and always safe.
|
|
1741
|
+
- **`ev.nativeEvent` can be null.** A keystroke carries the X key event that
|
|
1742
|
+
produced it, but an edit can also come from a paste resolving, an undo, or
|
|
1743
|
+
a value the parent pushed back — none of which has an X event behind it.
|
|
1744
|
+
Guard it. (Not academic: downshift reads
|
|
1745
|
+
`event.nativeEvent.preventDownshiftDefault` unguarded, so a null there is a
|
|
1746
|
+
TypeError rather than a no-op.)
|
|
1747
|
+
|
|
1748
|
+
The node behind `ev.target` is writable, too. `node.value = ''` sets the text
|
|
1749
|
+
without firing `onChange`, the way assigning to a DOM input's `value` does —
|
|
1750
|
+
that is how react-hook-form's `register()` resets a field through its ref.
|
|
1751
|
+
|
|
1752
|
+
Interactions: click/drag selection, double-click word select, triple-click
|
|
1753
|
+
select all, **dead keys and Compose**
|
|
1754
|
+
([events.md](events.md#composition)), Backspace/Delete, arrows (+Shift extends), Ctrl+arrows
|
|
1755
|
+
and Ctrl+Backspace/Delete by word, Home/End, Ctrl+A,
|
|
1756
|
+
Ctrl+C/X/V on CLIPBOARD, middle-click paste from PRIMARY, selections own
|
|
1757
|
+
PRIMARY (X11 conventions, select-all included; [X11 only](clipboard.md#primary-is-x11-only)), **Ctrl+Z / Ctrl+Shift+Z** (Ctrl+Y too) to undo
|
|
1758
|
+
and redo, and a **right-click menu**. On the Cocoa backend the keys are the
|
|
1759
|
+
Mac's instead: the chords are ⌘ — ⌘A, ⌘C/X/V, ⌘Z / ⇧⌘Z — ⌥ moves and deletes
|
|
1760
|
+
by word, ⌘←/→ and ⌘↑/↓ go to the ends of the line and of the value, ⌘⌫
|
|
1761
|
+
deletes to the start of the line, ⌃A, ⌃E, ⌃K, ⌃Y and the rest of AppKit's
|
|
1762
|
+
Emacs keys work while any other ⌃ chord types nothing, and ⌥ types what the
|
|
1763
|
+
layout puts on a key, `ß` for ⌥S ([events.md](events.md#primary-modifier)).
|
|
1764
|
+
Focusable by default; shows the text cursor. `ev.preventDefault()` in your
|
|
1765
|
+
`onKeyDown`/`onMouseDown` suppresses the built-in editing behavior. To copy
|
|
1766
|
+
or paste from anywhere else — a canvas, a list, your own menu item — see
|
|
1767
|
+
[clipboard.md](clipboard.md).
|
|
1768
|
+
|
|
1769
|
+
### The right-click menu
|
|
1770
|
+
|
|
1771
|
+

|
|
1772
|
+
|
|
1773
|
+
Right-clicking gives Undo / Redo / Cut / Copy / Paste / Select All with no
|
|
1774
|
+
wiring, the way a browser gives `<input>` one — each row live only when it
|
|
1775
|
+
would do something, and every row running the same code the keyboard
|
|
1776
|
+
shortcut does. Right-clicking **inside** a selection keeps it (the menu is
|
|
1777
|
+
about to act on it); outside one, the caret moves there first.
|
|
1778
|
+
|
|
1779
|
+
**Paste** is greyed when nothing owns the CLIPBOARD selection. The field
|
|
1780
|
+
subscribes to selection changes the first time its menu opens rather than
|
|
1781
|
+
asking on the way in — asking would mean a round trip against whatever
|
|
1782
|
+
foreign client owns the clipboard, and a wait if that client is wedged, at
|
|
1783
|
+
exactly the moment a menu should already be on screen. So the very first
|
|
1784
|
+
menu of a session still shows Paste enabled; every one after it knows. On
|
|
1785
|
+
a server without XFixes it stays enabled, which is where it started. On
|
|
1786
|
+
macOS it follows the pasteboard's change count, a quarter second behind a
|
|
1787
|
+
copy made in another app ([clipboard.md](clipboard.md#knowing-when-it-changes)).
|
|
1788
|
+
|
|
1789
|
+
Arrows walk the rows, skipping the disabled ones, Enter chooses, Escape or
|
|
1790
|
+
a press anywhere outside closes. The selection stays visibly highlighted
|
|
1791
|
+
while the menu is up, even though the popup holds the keyboard.
|
|
1792
|
+
|
|
1793
|
+
To replace it with your own, set `contextMenu={false}` and render a
|
|
1794
|
+
`ContextMenu` — `onContextMenu` still fires. To suppress it for one event,
|
|
1795
|
+
call `ev.preventDefault()` in an `onContextMenu` handler.
|
|
1796
|
+
|
|
1797
|
+
The menu is not the field's private property: `openEditMenu(node, at,
|
|
1798
|
+
actions)` opens this one for anything that edits or selects text — a code
|
|
1799
|
+
editor, a rendered document with a selection — and `<textinput>`'s own menu
|
|
1800
|
+
is a caller of it, which is what keeps the two the same menu. See
|
|
1801
|
+
[extending.md](extending.md#the-standard-edit-menu).
|
|
1802
|
+
|
|
1803
|
+
### `sensitive`
|
|
1804
|
+
|
|
1805
|
+
`<textinput sensitive>` is a field whose text **never reaches a selection**.
|
|
1806
|
+
Ctrl+C and the copy half of Ctrl+X do nothing, the menu offers neither Cut
|
|
1807
|
+
nor Copy — absent rows rather than greyed ones, because a greyed Copy over a
|
|
1808
|
+
password reads as a bug in the application — and selecting text does not take
|
|
1809
|
+
PRIMARY, so a middle click in another window cannot spend it. Everything else
|
|
1810
|
+
is untouched: the caret, the selection itself, the arrows, undo, and pasting
|
|
1811
|
+
_in_.
|
|
1812
|
+
|
|
1813
|
+
The line it draws is between what is on screen and what is on the clipboard.
|
|
1814
|
+
The first stops being visible when the field is hidden or the window closes;
|
|
1815
|
+
the second is readable by every client on the display until something else
|
|
1816
|
+
takes the selection, and a clipboard manager will have written it down.
|
|
1817
|
+
`PasswordInput` sets it on the input it shows while the secret is revealed —
|
|
1818
|
+
see [components.md](components.md#passwordinput). An input method is on the
|
|
1819
|
+
same side of the line: on Wayland a `sensitive` field is declared
|
|
1820
|
+
`sensitive_data` and none of its text is offered as surrounding text
|
|
1821
|
+
([events.md](events.md#input-methods)).
|
|
1822
|
+
|
|
1823
|
+
**Ref**: the node, plus `value`, `undo()` / `redo()` and `canUndo` /
|
|
1824
|
+
`canRedo` — enough for a toolbar button beside the field.
|
|
1825
|
+
|
|
1826
|
+
### Undo
|
|
1827
|
+
|
|
1828
|
+
Consecutive typing coalesces into one undo step, so Ctrl+Z takes back a
|
|
1829
|
+
word rather than a keystroke; a run of Backspaces coalesces the same way.
|
|
1830
|
+
A run ends at whitespace, at anything that moves the caret (arrows,
|
|
1831
|
+
Home/End, a click, focus leaving the field), and around edits that are
|
|
1832
|
+
their own step whatever surrounds them: a paste, a cut, a replaced
|
|
1833
|
+
selection, Ctrl+Backspace (⌥⌫ and ⌘⌫ on a Mac, and a ⌃K), and a `<textarea>`
|
|
1834
|
+
newline. Undo restores the
|
|
1835
|
+
selection and puts the caret back where the undone edit started.
|
|
1836
|
+
|
|
1837
|
+
A composed character is one entry, not one per keystroke: the accent a dead
|
|
1838
|
+
key is holding is not in the value until it commits, so Ctrl+Z after
|
|
1839
|
+
`dead_acute` `e` steps over `é` rather than into it.
|
|
1840
|
+
|
|
1841
|
+
Undo is a stack of snapshots of the states the control has shown, capped
|
|
1842
|
+
at 200. A **controlled** `value` reports the restored text through
|
|
1843
|
+
`onChange` and waits for the prop to come back, exactly as typing does —
|
|
1844
|
+
so a parent that filters or rejects a value gets the same say over an undo
|
|
1845
|
+
that it has over an edit. A value changed from outside (a form reset)
|
|
1846
|
+
becomes its own entry, and undoing steps back through it.
|
|
1847
|
+
|
|
1848
|
+
## `<textarea>`
|
|
1849
|
+
|
|
1850
|
+
Multi-line editable text on the same editing core as `<textinput>`:
|
|
1851
|
+
word-wraps at the content width, Enter and Shift+Enter insert a newline
|
|
1852
|
+
(Ctrl+Enter fires `onSubmit`, ⌘↩ on the Cocoa backend, and with no `onSubmit`
|
|
1853
|
+
is left to a shortcut, as Alt+Enter and Super+Enter are everywhere but there —
|
|
1854
|
+
[events.md](events.md#accelerators)), Up/Down move between visual lines keeping a goal column,
|
|
1855
|
+
Home/End go to the start/end of the visual (wrapped) line, selection spans
|
|
1856
|
+
lines, and the view scrolls vertically to follow the caret (mouse wheel
|
|
1857
|
+
scrolls too). On the Cocoa backend ⌘←/→ are Home/End, ⌘↑/↓ go to the ends of
|
|
1858
|
+
the value and ⌥↑/↓ to the ends of a paragraph
|
|
1859
|
+
([events.md](events.md#primary-modifier)).
|
|
1860
|
+
|
|
1861
|
+
The wheel reaches it through the same chain a `<box overflow="scroll">` is
|
|
1862
|
+
in, so a field whose text fits passes the gesture out to the pane or the
|
|
1863
|
+
window behind it rather than swallowing it.
|
|
1864
|
+
|
|
1865
|
+
| prop | |
|
|
1866
|
+
| --------------- | -------------------------------------------------------------- |
|
|
1867
|
+
| `rows` | preferred height in text lines (default 3) |
|
|
1868
|
+
| everything else | as `<textinput>` (`onSubmit` fires on Ctrl+Enter, ⌘↩ on Cocoa) |
|
|
1869
|
+
|
|
1870
|
+
## `<image>`
|
|
1871
|
+
|
|
1872
|
+
| prop | |
|
|
1873
|
+
| ---------- | -------------------------------------------------------------------------------------- |
|
|
1874
|
+
| `src` | client-side pixels, in any of the four forms below, or a symbol by name |
|
|
1875
|
+
| `picture` | `{ id, width, height }` — an existing server-side Picture, composited as-is |
|
|
1876
|
+
| `drawable` | `{ id, width, height, depth? }` — an existing Pixmap/Window, composited as-is |
|
|
1877
|
+
| `cacheKey` | the source's identity, when `src` is re-derived per render |
|
|
1878
|
+
| `alt` | the accessible name — what a screen reader says ([accessibility.md](accessibility.md)) |
|
|
1879
|
+
|
|
1880
|
+
One source per element — `src`, `picture` and `drawable` are mutually
|
|
1881
|
+
exclusive, and passing two throws.
|
|
1882
|
+
|
|
1883
|
+
Sized by style — `style={{ width, height }}`, never flat props, since both
|
|
1884
|
+
are style names. With only one of the two set the other follows the natural
|
|
1885
|
+
aspect ratio; with neither, the image measures at its natural size, shrunk
|
|
1886
|
+
to the width on offer. For the server-side sources the "natural size" is the
|
|
1887
|
+
`width`/`height` stated in the descriptor.
|
|
1888
|
+
|
|
1889
|
+
```jsx
|
|
1890
|
+
<image src={photo} style={{ height: 64 }} /> // width follows the aspect ratio
|
|
1891
|
+
```
|
|
1892
|
+
|
|
1893
|
+
`objectFit` decides where the picture goes inside that box when the two
|
|
1894
|
+
shapes differ: `'fill'` — the default — stretches it to the box, `'contain'`
|
|
1895
|
+
fits it inside with the background around it, `'cover'` fills the box and
|
|
1896
|
+
cuts what overflows, `'none'` leaves it its own size and `'scale-down'` is
|
|
1897
|
+
the smaller of `'none'` and `'contain'`, all centred.
|
|
1898
|
+
|
|
1899
|
+
```jsx
|
|
1900
|
+
<image src={photo} style={{ width: 96, height: 96, objectFit: 'cover' }} />
|
|
1901
|
+
```
|
|
1902
|
+
|
|
1903
|
+
### `src` — pixels the client has
|
|
1904
|
+
|
|
1905
|
+
```jsx
|
|
1906
|
+
<image src="./logo.png" /> // file path or file URL
|
|
1907
|
+
<image src={bytes} /> // encoded PNG, JPEG, WebP, …
|
|
1908
|
+
<image src={{ width, height, data }} /> // raw straight RGBA
|
|
1909
|
+
<image src={ntkImage} /> // an ntk Image (or Surface)
|
|
1910
|
+
```
|
|
1911
|
+
|
|
1912
|
+
A file path (or `new URL('./logo.png', import.meta.url)`) is read and
|
|
1913
|
+
decoded asynchronously; the element measures 0×0 until the decode lands,
|
|
1914
|
+
then reflows. Style it a `width` and `height` and nothing moves when it
|
|
1915
|
+
does. The in-memory forms skip the filesystem — pixels that arrived from a
|
|
1916
|
+
socket, a decoder, or a `getImageData` readback go straight on screen — and
|
|
1917
|
+
raw RGBA and an ntk `Image` are synchronous. Encoded bytes are synchronous
|
|
1918
|
+
where the decoder is (PNG and JPEG under Node) and land a moment later, the
|
|
1919
|
+
way a file does, where it is not: any format under Bun, and WebP elsewhere.
|
|
1920
|
+
Raw `data` is `width × height × 4` bytes of straight (non-premultiplied)
|
|
1921
|
+
RGBA — exactly what `getImageData` hands back — and the object is treated as
|
|
1922
|
+
immutable content: hand over a new object when the pixels change, or the
|
|
1923
|
+
renderer cannot tell. That goes for encoded bytes too, which under Bun are
|
|
1924
|
+
read on another thread after the commit.
|
|
1925
|
+
|
|
1926
|
+
#### Formats, and what decodes them
|
|
1927
|
+
|
|
1928
|
+
The format comes from the bytes, never from a file name. Which decoder
|
|
1929
|
+
reads them depends on the runtime:
|
|
1930
|
+
|
|
1931
|
+
| format | under Bun | under Node |
|
|
1932
|
+
| ---------------- | -------------------------------------------------- | ------------------------------------------ |
|
|
1933
|
+
| PNG, JPEG | `Bun.Image`, off the JavaScript thread | pngjs, jpeg-js — synchronous |
|
|
1934
|
+
| WebP | `Bun.Image`, off the JavaScript thread | image-in-browser, loaded on the first WebP |
|
|
1935
|
+
| GIF, BMP | `Bun.Image`, off the JavaScript thread | not decoded — the error says to convert |
|
|
1936
|
+
| TIFF, HEIC, AVIF | `Bun.Image` through ImageIO (macOS), WIC (Windows) | not decoded; nor under Bun on Linux |
|
|
1937
|
+
|
|
1938
|
+
**Under Bun, Bun decodes everything**, PNG and JPEG included, which the
|
|
1939
|
+
JavaScript decoders could read: libjpeg-turbo, spng and libwebp are built
|
|
1940
|
+
into the runtime and run on a worker thread. A 1080p JPEG takes ~110ms of the
|
|
1941
|
+
JavaScript thread inside jpeg-js and ~7ms under Bun, which is the difference
|
|
1942
|
+
between a stall the user feels and none. What it costs is that bytes under
|
|
1943
|
+
Bun land a frame or so late, like a file. Where that matters — a test that
|
|
1944
|
+
measures the element on its first frame — decode the bytes yourself and pass
|
|
1945
|
+
the result, which is used as-is on every runtime:
|
|
1946
|
+
|
|
1947
|
+
```jsx
|
|
1948
|
+
import { decodeImage } from 'ntk/image'; // PNG and JPEG, synchronous
|
|
1949
|
+
|
|
1950
|
+
const logo = <image src={decodeImage(bytes)} />;
|
|
1951
|
+
```
|
|
1952
|
+
|
|
1953
|
+
The WebP decoder under Node is pure JavaScript, imported the first time a
|
|
1954
|
+
WebP arrives so an app that shows none never loads it: the first WebP pays
|
|
1955
|
+
~140ms to load the decoder, and each one about what jpeg-js costs a JPEG of
|
|
1956
|
+
the same size.
|
|
1957
|
+
|
|
1958
|
+
An element that draws images of its own — a document's `<img>`, a CSS
|
|
1959
|
+
background — decodes through the same ladder, so that an image shows there
|
|
1960
|
+
exactly when it would show in an `<image>`: `decodeImageBytes` from
|
|
1961
|
+
`react-x11/ntk` takes the bytes and answers an `Image`, or a promise of one
|
|
1962
|
+
wherever the table above says the decode is not synchronous, and throws or
|
|
1963
|
+
rejects, saying what to convert to, where an `<image>` would show nothing.
|
|
1964
|
+
|
|
1965
|
+
What differs between the two, so it is not found the hard way:
|
|
1966
|
+
|
|
1967
|
+
- **An animation shows its first frame** — an animated WebP on either
|
|
1968
|
+
runtime, a GIF under Bun. Bun's libwebp cannot decode an animated WebP at
|
|
1969
|
+
all (Bun 1.4), so under Bun that one goes to the JavaScript decoder too.
|
|
1970
|
+
- **A JPEG's EXIF orientation is applied under Bun and not under Node.**
|
|
1971
|
+
Browsers apply it, and so does Bun; jpeg-js does not, so under Node a
|
|
1972
|
+
phone photo taken in portrait shows on its side. Rotate it before
|
|
1973
|
+
handing it over, or run under Bun.
|
|
1974
|
+
- **A format with no decoder says so**: GIF under Node, or HEIC under Bun on
|
|
1975
|
+
Linux, is logged with what to convert it to, and the element shows
|
|
1976
|
+
nothing, the same as a missing file.
|
|
1977
|
+
|
|
1978
|
+
Decoded pixels are uploaded to the server once and composited from there on
|
|
1979
|
+
every repaint (ntk's `Image` caches its upload per connection), so the cost
|
|
1980
|
+
of an image is one `PutImage` at first paint, not one per frame.
|
|
1981
|
+
|
|
1982
|
+
An ntk `Image` is used as-is and never destroyed by the element — identity
|
|
1983
|
+
is yours, which is also the sharing idiom: one `Image` shown by many
|
|
1984
|
+
`<image>`s is one decode and one upload, however many places composite it.
|
|
1985
|
+
|
|
1986
|
+
On the Cocoa backend (macOS's default) the upload is a CoreGraphics bitmap
|
|
1987
|
+
rather than a pixmap — made once per `Image` at its first paint and
|
|
1988
|
+
composited, scaled, from there — so the cost is the same one upload. The
|
|
1989
|
+
element frees it with an `Image` it decoded; for an `Image` of yours it is
|
|
1990
|
+
freed when the `Image` is garbage-collected, since ntk's `destroy()` frees
|
|
1991
|
+
only its X copies.
|
|
1992
|
+
|
|
1993
|
+
### `src={{ symbol }}` — the platform's own icons
|
|
1994
|
+
|
|
1995
|
+
```jsx
|
|
1996
|
+
<box style={{ flexDirection: 'row', gap: 6, color: theme.textMuted }}>
|
|
1997
|
+
<image src={{ symbol: 'speaker.wave.3.fill', variableValue: volume }} />
|
|
1998
|
+
<text>Volume</text>
|
|
1999
|
+
</box>
|
|
2000
|
+
```
|
|
2001
|
+
|
|
2002
|
+
A name instead of pixels: the icon the platform draws for it, in the **text
|
|
2003
|
+
colour** — the colour a `<text>` beside it would be drawn in, which is what
|
|
2004
|
+
`currentColor` is to an `<svg>`. The names are the ones `useTray`'s `icon`
|
|
2005
|
+
and a menu item's `iconName` take:
|
|
2006
|
+
|
|
2007
|
+
| backend | names |
|
|
2008
|
+
| ------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
2009
|
+
| macOS | SF Symbols, like `speaker.wave.3.fill`, through `@windowkit/appkit` |
|
|
2010
|
+
| X11 / Wayland | the user's freedesktop icon theme — the settings daemon's, else Adwaita, then hicolor — like `audio-volume-high` |
|
|
2011
|
+
|
|
2012
|
+
A symbol is **sized like the text around it**. An SF Symbol takes the
|
|
2013
|
+
`fontSize` and `fontWeight` it inherits as its point size and weight, the
|
|
2014
|
+
way the symbols are designed to sit in a line of text; an icon theme is asked
|
|
2015
|
+
for the size that text calls for — 16 beside 14px text — and its icons are
|
|
2016
|
+
square. Style the element a `width` and `height` of its own and the symbol is
|
|
2017
|
+
fitted into that box, centred. The source can say otherwise:
|
|
2018
|
+
|
|
2019
|
+
| key | |
|
|
2020
|
+
| --------------- | ------------------------------------------------------------------------------ |
|
|
2021
|
+
| `symbol` | the name |
|
|
2022
|
+
| `weight` | what `fontWeight` takes; defaults to the text's. SF Symbols only |
|
|
2023
|
+
| `scale` | `'small'`, `'medium'` (default) or `'large'`, beside the text. SF Symbols only |
|
|
2024
|
+
| `variableValue` | 0 to 1, how much of a variable symbol shows. SF Symbols, macOS 13 and later |
|
|
2025
|
+
|
|
2026
|
+
On Linux a `-symbolic` icon is preferred where the theme has one, and it is
|
|
2027
|
+
the one that takes the text colour — the monochrome set GTK tints the same
|
|
2028
|
+
way. An icon the theme only has in its own colours is shown as it is. The
|
|
2029
|
+
lookup is the Icon Theme Specification's, read when the first symbol is
|
|
2030
|
+
drawn.
|
|
2031
|
+
|
|
2032
|
+
**A name this desktop does not have takes no room and draws nothing**, and
|
|
2033
|
+
says so once in development. An app that runs on both systems picks its
|
|
2034
|
+
names by platform, as it does for the tray: the two icon sets do not share
|
|
2035
|
+
their names.
|
|
2036
|
+
|
|
2037
|
+
### `cacheKey` — when the buffer is new but the picture is not
|
|
2038
|
+
|
|
2039
|
+
A component that re-derives its bytes per render — decoding a protocol
|
|
2040
|
+
stream, slicing a capture — hands `<image>` a structurally new `src` every
|
|
2041
|
+
time, and without help that is a fresh decode and a fresh upload each
|
|
2042
|
+
render. `cacheKey` is the `<canvas cacheKey>` contract applied to sources:
|
|
2043
|
+
an unchanged key vouches that the new buffer is the same picture, so nothing
|
|
2044
|
+
is re-decoded or re-uploaded — and two `<image>`s with one key share one
|
|
2045
|
+
decoded copy, freed when the last of them unmounts.
|
|
2046
|
+
|
|
2047
|
+
```jsx
|
|
2048
|
+
<image src={frame.rgba} cacheKey={`frame:${frame.serial}`} />
|
|
2049
|
+
```
|
|
2050
|
+
|
|
2051
|
+
The key must name the content: a key that stays the same while the pixels
|
|
2052
|
+
change shows the old pixels. Development warns when two sizes collide under
|
|
2053
|
+
one key, which is the cheap symptom of that mistake. The key is not
|
|
2054
|
+
consulted for an ntk `Image` (the object is its own identity), and is
|
|
2055
|
+
refused with `picture`/`drawable` (there is nothing client-side to cache).
|
|
2056
|
+
|
|
2057
|
+
### `picture` / `drawable` — pixels the server already has
|
|
2058
|
+
|
|
2059
|
+
When the content is already a server-side Picture or Drawable — a pixmap the
|
|
2060
|
+
app rendered offscreen, `NameWindowPixmap` from Composite, a cached tile —
|
|
2061
|
+
showing it must not mean reading it back and uploading it again. These
|
|
2062
|
+
composite straight from the existing resource: one `RenderComposite` into
|
|
2063
|
+
the window, no `PutImage`, no round trip.
|
|
2064
|
+
|
|
2065
|
+
```jsx
|
|
2066
|
+
<image picture={{ id: pic, width: 64, height: 64 }} />
|
|
2067
|
+
<image drawable={{ id: pixmap, width: 128, height: 96, depth: 32 }} />
|
|
2068
|
+
```
|
|
2069
|
+
|
|
2070
|
+
The caller states the size because asking the server for it would be a round
|
|
2071
|
+
trip, which these props exist to avoid. The descriptor is compared by value,
|
|
2072
|
+
so an object literal rebuilt every render costs nothing. Scaling (a box
|
|
2073
|
+
styled to a different size than the source) is server-side, through the
|
|
2074
|
+
picture transform with bilinear filtering — note that drawing a `picture`
|
|
2075
|
+
scaled sets that picture's transform and filter for the composite and resets
|
|
2076
|
+
them to identity/nearest after, the same bracket ntk puts around its own
|
|
2077
|
+
uploads; a picture that must keep a transform of its own should be
|
|
2078
|
+
composited 1:1.
|
|
2079
|
+
|
|
2080
|
+
`drawable` wraps the pixmap or window in a Picture the element creates and
|
|
2081
|
+
owns (freed on unmount; the drawable stays yours). `depth` picks the format
|
|
2082
|
+
it is composited through: 24 — the screen's default depth, what a window
|
|
2083
|
+
pixmap from Composite is — is the default, 32 is argb, and 8 composites as
|
|
2084
|
+
ink through its alpha, which is what previewing a mask looks like. A
|
|
2085
|
+
`picture` needs no depth: its format was fixed when it was created.
|
|
2086
|
+
|
|
2087
|
+
Both name X server resources, so both are X11-only: the Cocoa backend has no
|
|
2088
|
+
X server to composite from, and there they draw nothing (development warns
|
|
2089
|
+
once). Hand `src` the pixels instead.
|
|
2090
|
+
|
|
2091
|
+
Both descriptors also accept the richer objects that already carry these
|
|
2092
|
+
fields — an ntk `Pixmap` is `{ id, width, height, depth }` and goes straight
|
|
2093
|
+
in as `drawable`.
|
|
2094
|
+
|
|
2095
|
+
## `<video>` {#video}
|
|
2096
|
+
|
|
2097
|
+
```jsx
|
|
2098
|
+
// frames the application decodes — every backend
|
|
2099
|
+
const frames = useVideoFrames({ width: 1280, height: 720, format: 'NV12' });
|
|
2100
|
+
useEffect(() => startDecoder(file, frames), [frames, file]);
|
|
2101
|
+
<video frames={frames} style={{ width: '100%', backgroundColor: '#000' }} />;
|
|
2102
|
+
```
|
|
2103
|
+
|
|
2104
|
+
| prop | |
|
|
2105
|
+
| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
2106
|
+
| `frames` | a `VideoFrames` sink the application pushes decoded frames into (`useVideoFrames`, `createVideoFrames`) — works on every backend |
|
|
2107
|
+
| `src` | a path or URL the platform plays itself, where `useSupports('mediaPlayback')` is true; elsewhere one `NoMediaPlaybackError` through `onError` |
|
|
2108
|
+
| `poster` | an `<image src>` value, shown until there is a frame and whenever there is none |
|
|
2109
|
+
| `autoPlay`, `loop`, `muted`, `volume`, `playbackRate` | HTML's names and defaults — nothing plays until asked; for `src` |
|
|
2110
|
+
| `paused` | controlled when present; for `src` |
|
|
2111
|
+
| `onLoadedMetadata` | `{ width, height, duration }` once the stream's size is known — for a sink, once it is mounted, with a `duration` of `Infinity`, a live source's |
|
|
2112
|
+
| `onPlay`, `onPause`, `onEnded`, `onTimeUpdate` | for `src`; the `ref` has `play()`, `pause()`, `seek(seconds)`, `currentTime`, `duration` and `paused` |
|
|
2113
|
+
| `onError` | the source failed, or there is no player for `src`; without a handler, one console warning |
|
|
2114
|
+
|
|
2115
|
+
`frames` and `src` are exclusive, and passing both throws. The element is a
|
|
2116
|
+
drawn node like `<image>` — in the layout, in the paint order, hit like
|
|
2117
|
+
anything else — so a video in a form sits under the form's popups and inside
|
|
2118
|
+
its scroll panes, which a child window or a `<glarea>` can do neither of.
|
|
2119
|
+
|
|
2120
|
+
**Size.** HTML's: the stream's size in logical pixels — a 1280x720 stream is
|
|
2121
|
+
1280 wide at any scale, as an image is — then the poster's, then 300x150. A
|
|
2122
|
+
style that names one axis gets the other from the picture's ratio, larger
|
|
2123
|
+
than the stream as well as smaller. `objectFit` is `'contain'` unless the
|
|
2124
|
+
style says otherwise, and `backgroundColor` is what shows in the bars.
|
|
2125
|
+
|
|
2126
|
+
### `VideoFrames`, the sink
|
|
2127
|
+
|
|
2128
|
+
react-x11 decodes nothing; where the bytes come from is the application's
|
|
2129
|
+
business, and every source ends in `push`:
|
|
2130
|
+
|
|
2131
|
+
```js
|
|
2132
|
+
import { spawn } from 'node:child_process';
|
|
2133
|
+
|
|
2134
|
+
// ffmpeg decoding a file into NV12 frames on a pipe, paced to the clip's rate
|
|
2135
|
+
function startDecoder(file, frames) {
|
|
2136
|
+
const ff = spawn('ffmpeg', [
|
|
2137
|
+
'-re',
|
|
2138
|
+
'-i',
|
|
2139
|
+
file,
|
|
2140
|
+
'-f',
|
|
2141
|
+
'rawvideo',
|
|
2142
|
+
'-pix_fmt',
|
|
2143
|
+
frames.format.toLowerCase(),
|
|
2144
|
+
'-s',
|
|
2145
|
+
`${frames.width}x${frames.height}`,
|
|
2146
|
+
'-',
|
|
2147
|
+
]);
|
|
2148
|
+
const size = frames.width * frames.height * 1.5; // NV12
|
|
2149
|
+
let pending = Buffer.alloc(0);
|
|
2150
|
+
ff.stdout.on('data', (chunk) => {
|
|
2151
|
+
pending = Buffer.concat([pending, chunk]);
|
|
2152
|
+
while (pending.length >= size) {
|
|
2153
|
+
frames.push(pending.subarray(0, size)); // one buffer, planes back to back
|
|
2154
|
+
pending = pending.subarray(size);
|
|
2155
|
+
}
|
|
2156
|
+
});
|
|
2157
|
+
return () => ff.kill();
|
|
2158
|
+
}
|
|
2159
|
+
```
|
|
2160
|
+
|
|
2161
|
+
- **Formats.** `'NV12'` (Y, then Cb/Cr pairs — what a hardware decoder
|
|
2162
|
+
emits), `'I420'` (Y, Cb, Cr — `yuv420p`, most software decoders' default)
|
|
2163
|
+
and `'BGRA'` (the fourth byte ignored: a frame is opaque). `colorSpace`
|
|
2164
|
+
(`'bt709'` by default, `'bt601'`, `'bt2020'`) and `range` (`'video'` by
|
|
2165
|
+
default, `'full'`) say what a YCbCr frame's numbers mean.
|
|
2166
|
+
- **`preferredFormats`** is this display's order, cheapest first, for a
|
|
2167
|
+
decoder that can choose: `NV12, I420, BGRA` on macOS, where a YCbCr frame
|
|
2168
|
+
goes on a layer as it is, and `BGRA` wherever a 2D context draws it. Every
|
|
2169
|
+
format works everywhere; the conversion is charged where it happens.
|
|
2170
|
+
- **The newest frame is the frame.** A push replaces the last one, shown or
|
|
2171
|
+
not; nothing queues, and the decoder's own clock is the pacing. A push
|
|
2172
|
+
hands its planes over until the next push — they are read then, not
|
|
2173
|
+
copied at the call — so a decoder that writes every frame into one buffer
|
|
2174
|
+
pushes it again after writing.
|
|
2175
|
+
- **`push(planes, { strides, time })`** takes a Buffer per plane or one
|
|
2176
|
+
Buffer of them back to back, with each plane's bytes per row when a
|
|
2177
|
+
decoder pads them; a plane too small for the frame is a `TypeError` naming
|
|
2178
|
+
the plane and the bytes it needs. `close()` drops the frame and ignores
|
|
2179
|
+
what comes after, so a decoder racing an unmount loses quietly.
|
|
2180
|
+
- **Nothing to free.** A sink owns nothing native: what shows it makes its
|
|
2181
|
+
surfaces and lets them go when it stops showing it.
|
|
2182
|
+
|
|
2183
|
+
### Lifted, drawn, and what each costs
|
|
2184
|
+
|
|
2185
|
+
On **macOS** a video with a frame to show goes on a layer of its own above
|
|
2186
|
+
the window's bitmap, under the same rule that lifts an animation
|
|
2187
|
+
([macos.md](macos.md)): nothing painted over it, no fade, no rounded
|
|
2188
|
+
clipping ancestor that cannot hold it. There every `push` writes the frame
|
|
2189
|
+
into a surface the render server shows as it is and repoints the layer —
|
|
2190
|
+
0.07ms for a 1080p NV12 frame, 0.27ms for an I420 one interleaved on the
|
|
2191
|
+
way — with **no frame of the window's own**. The rest of the time — a
|
|
2192
|
+
tooltip over it, a dialog's dim, a card with rounded corners — it is
|
|
2193
|
+
**drawn**: a push claims the picture, and the next frame copies the newest
|
|
2194
|
+
frame into a surface of the element's own and composites it in paint
|
|
2195
|
+
order. The bridge converts a YCbCr frame into that surface in the colours
|
|
2196
|
+
the layer shows it in, so the picture does not change shade on the way
|
|
2197
|
+
between the two.
|
|
2198
|
+
|
|
2199
|
+
On **X11, Wayland and Windows** it is always drawn, the frame converted to
|
|
2200
|
+
RGBA in JavaScript unless it is BGRA — which is why those displays prefer
|
|
2201
|
+
BGRA. On X11 the upload rides ntk's shared-memory path where the server has
|
|
2202
|
+
it. No transfer function is applied there, as nothing else on those
|
|
2203
|
+
displays is colour-managed either.
|
|
2204
|
+
|
|
2205
|
+
`borderRadius` on an ancestor that clips the video costs the layer, not the
|
|
2206
|
+
picture: the video is drawn for as long as the rounded clip holds it.
|
|
2207
|
+
|
|
2208
|
+
### `src` — the platform's player
|
|
2209
|
+
|
|
2210
|
+
Where `useSupports('mediaPlayback')` is true, `src` is a file or URL the
|
|
2211
|
+
platform plays with its own decoder, audio, seeking and streaming — on macOS
|
|
2212
|
+
AVFoundation, over a bridge with its player verbs (`createPlayer`), and in
|
|
2213
|
+
a `<Frame>` pane, whose host plays it and sends the frames down
|
|
2214
|
+
([frame.md](frame.md#sizing-stacking-input)). A path, a `file://` URL and an
|
|
2215
|
+
`http(s)` one, HLS included, all play.
|
|
2216
|
+
|
|
2217
|
+
```jsx
|
|
2218
|
+
const ref = useRef(null);
|
|
2219
|
+
<video
|
|
2220
|
+
ref={ref}
|
|
2221
|
+
src="/Users/me/clip.mp4"
|
|
2222
|
+
autoPlay
|
|
2223
|
+
muted
|
|
2224
|
+
onLoadedMetadata={({ duration }) => setDuration(duration)}
|
|
2225
|
+
onTimeUpdate={({ currentTime }) => setAt(currentTime)}
|
|
2226
|
+
onEnded={() => setDone(true)}
|
|
2227
|
+
style={{ width: 640 }}
|
|
2228
|
+
/>;
|
|
2229
|
+
// a transport bar of your own
|
|
2230
|
+
<button onPress={() => ref.current.seek(30)} />;
|
|
2231
|
+
```
|
|
2232
|
+
|
|
2233
|
+
It is laid out at 300x150 — or the poster's size — until the item is ready,
|
|
2234
|
+
and at the clip's size from `onLoadedMetadata` on. `paused` is controlled
|
|
2235
|
+
when present; `autoPlay`, `loop`, `muted`, `volume` and `playbackRate` are
|
|
2236
|
+
HTML's, and a change to any of them goes to the same player. The `ref` is
|
|
2237
|
+
HTMLMediaElement's imperative half: `play()` (from the start if the item has
|
|
2238
|
+
ended), `pause()`, `seek(seconds)` — exact, not to the nearest keyframe —
|
|
2239
|
+
and `currentTime`, `duration` and `paused`. `onPlay` and `onPause` fire when
|
|
2240
|
+
it starts and stops, `onTimeUpdate` four times a second while it plays and
|
|
2241
|
+
after a seek, `onEnded` at the end of an item that does not loop, and
|
|
2242
|
+
`onError` when the item fails.
|
|
2243
|
+
|
|
2244
|
+
Lifted, the picture is the player's own layer and costs the window nothing
|
|
2245
|
+
while it plays; painted over, the frame showing is copied into the element's
|
|
2246
|
+
surface at the clip's own frame rate for as long as it plays, in the same
|
|
2247
|
+
colours. There is no `controls` attribute: the transport is your React,
|
|
2248
|
+
over or beside the video.
|
|
2249
|
+
|
|
2250
|
+
Where `useSupports('mediaPlayback')` is false — X11 and Wayland for good,
|
|
2251
|
+
Windows today, and macOS over an older bridge — the element reports one
|
|
2252
|
+
`NoMediaPlaybackError` (`code: 'ENOMEDIAPLAYBACK'`) through `onError`, on a
|
|
2253
|
+
microtask, and shows its poster: never a black box that looks like a video
|
|
2254
|
+
that has not started. Ask before rendering one:
|
|
2255
|
+
|
|
2256
|
+
```jsx
|
|
2257
|
+
const playback = useSupports('mediaPlayback');
|
|
2258
|
+
{
|
|
2259
|
+
playback ? <video src={file} autoPlay /> : <video frames={decoded} />;
|
|
2260
|
+
}
|
|
2261
|
+
```
|
|
2262
|
+
|
|
2263
|
+
## `<canvas>`
|
|
2264
|
+
|
|
2265
|
+
The escape hatch: a retained node whose content you paint.
|
|
2266
|
+
|
|
2267
|
+
```jsx
|
|
2268
|
+
<canvas
|
|
2269
|
+
style={{ flexGrow: 1 }}
|
|
2270
|
+
onDraw={(ctx, { width, height, node }) => {
|
|
2271
|
+
ctx.fillStyle = 'tomato';
|
|
2272
|
+
ctx.fillRect(0, 0, width / 2, height);
|
|
2273
|
+
}}
|
|
2274
|
+
/>
|
|
2275
|
+
```
|
|
2276
|
+
|
|
2277
|
+
`ctx` is ntk's canvas-like 2d context (paths, transforms, gradients incl.
|
|
2278
|
+
conical, `setLineDash`, round caps/joins, images, text — XRender-backed),
|
|
2279
|
+
translated to the node's origin and clipped to its bounds. `onDraw` runs on
|
|
2280
|
+
every repaint of the window.
|
|
2281
|
+
|
|
2282
|
+
A drawing made of many small rectangles in one colour — a sparkline, a heat
|
|
2283
|
+
map, row striping, terminal cells — has a batch primitive, and the
|
|
2284
|
+
difference is a whole frame's worth of requests:
|
|
2285
|
+
|
|
2286
|
+
```jsx
|
|
2287
|
+
ctx.fillStyle = theme.accent;
|
|
2288
|
+
ctx.fillRects(bars); // [[x, y, w, h], …] or one flat [x, y, w, h, x, …]
|
|
2289
|
+
```
|
|
2290
|
+
|
|
2291
|
+
`fillRects` is `fillRect` per rectangle — the same fill style, alpha,
|
|
2292
|
+
composite op and clip — sent as a single `Render.FillRectangles` where the
|
|
2293
|
+
loop would have sent one composite each.
|
|
2294
|
+
|
|
2295
|
+
### Raw pixels — the one call that is not translated
|
|
2296
|
+
|
|
2297
|
+
Everything on the context honours "you are at the node's origin" — except
|
|
2298
|
+
the raw-pixel pair. `putImageData` and `getImageData` ignore the current
|
|
2299
|
+
transform _and_ the clip (the HTML canvas rule, kept by ntk), so their
|
|
2300
|
+
coordinates are the drawable's own: `ctx.putImageData(data, 0, 0)` lands at
|
|
2301
|
+
the **window** corner, not in the node, and nothing errors on the way.
|
|
2302
|
+
`onDraw`'s second argument carries the node's origin for exactly this case:
|
|
2303
|
+
|
|
2304
|
+
```jsx
|
|
2305
|
+
<canvas
|
|
2306
|
+
onDraw={(ctx, { width, height, x, y }) => {
|
|
2307
|
+
ctx.putImageData({ width, height, data: rgba }, x, y); // in the node
|
|
2308
|
+
}}
|
|
2309
|
+
style={{ width, height }}
|
|
2310
|
+
/>
|
|
2311
|
+
```
|
|
2312
|
+
|
|
2313
|
+
`x`/`y` are wherever the drawing is going — in a live paint the node's place
|
|
2314
|
+
in the window, under a [`cacheKey`](#cachekey--draw-it-once) the origin of
|
|
2315
|
+
the cache surface — so offsetting by them is correct in both. In
|
|
2316
|
+
development, a `putImageData` whose pixels land outside the node's box
|
|
2317
|
+
warns, once, naming this rule.
|
|
2318
|
+
|
|
2319
|
+
Better, for pixels that survive more than one frame: upload through an
|
|
2320
|
+
`Image` source (ntk's, re-exported — see [the subpath
|
|
2321
|
+
exports](extending.md#the-subpath-exports)) and draw it.
|
|
2322
|
+
`drawImage` goes through the transform and the clip like everything else — a
|
|
2323
|
+
half-scrolled-out canvas stays cut at the viewport, which no raw write is —
|
|
2324
|
+
and the image caches its server-side pixmap instead of re-uploading the
|
|
2325
|
+
bytes on every repaint:
|
|
2326
|
+
|
|
2327
|
+
```jsx
|
|
2328
|
+
import { Image } from 'react-x11/ntk';
|
|
2329
|
+
|
|
2330
|
+
const img = new Image({ width, height, data: rgba }); // keep it memoized
|
|
2331
|
+
<canvas onDraw={(ctx) => ctx.drawImage(img, 0, 0, width, height)} />;
|
|
2332
|
+
```
|
|
2333
|
+
|
|
2334
|
+
### `cacheKey` — draw it once
|
|
2335
|
+
|
|
2336
|
+
A drawing that does not change between frames does not have to be redrawn
|
|
2337
|
+
between frames. Give the canvas a `cacheKey` and its content is rendered once
|
|
2338
|
+
and composited on later repaints; two canvases with the same key share the one
|
|
2339
|
+
rendered copy.
|
|
2340
|
+
|
|
2341
|
+
```jsx
|
|
2342
|
+
<canvas cacheKey={`spark:${series.id}:${w}x${h}`} onDraw={drawSparkline} />
|
|
2343
|
+
```
|
|
2344
|
+
|
|
2345
|
+
Opt-in, and the reason matters: `onDraw` is an opaque closure. Nothing in the
|
|
2346
|
+
renderer can know what it reads — a prop, a ref, a clock — and its identity
|
|
2347
|
+
changes on every render unless you memoize it, so it is not a key either. Only
|
|
2348
|
+
you know, so you say.
|
|
2349
|
+
|
|
2350
|
+
**The key must name every input the drawing reads.** One that leaves something
|
|
2351
|
+
out shows stale pixels, which is the hardest kind of bug to see. Develop with
|
|
2352
|
+
`REACT_X11_PAINT_CACHE=verify`, which turns exactly that mistake into a loud
|
|
2353
|
+
complaint — see [Runtime diagnostics](debugging.md#the-paint-cache--react_x11_no_paint_cache-react_x11_paint_cacheverify).
|
|
2354
|
+
Leave `cacheKey` unset for anything animated, or driven by state outside the
|
|
2355
|
+
props.
|
|
2356
|
+
|
|
2357
|
+
`<svg>` does this automatically: its content is fully described by
|
|
2358
|
+
its props, so the renderer can build the key itself.
|
|
2359
|
+
|
|
2360
|
+
### `mono` — one colour, and the colour out of the key
|
|
2361
|
+
|
|
2362
|
+
`mono` is a promise about the drawing: **everything it paints is one colour,
|
|
2363
|
+
and it is not the drawing's to choose.** `onDraw` then names no colour at all
|
|
2364
|
+
— `fillStyle` and `strokeStyle` arrive preset from the node's resolved
|
|
2365
|
+
`color`, which is its own if it named one and otherwise the ink it inherits
|
|
2366
|
+
([styling.md](styling.md#inheritance-the-ink-the-face-and-the-size)), so a
|
|
2367
|
+
mark inside a dimmed or hovered row follows it with nothing said:
|
|
2368
|
+
|
|
2369
|
+
```jsx
|
|
2370
|
+
const chevron = (ctx, { width: w, height: h }) => {
|
|
2371
|
+
ctx.lineWidth = 1.5;
|
|
2372
|
+
ctx.beginPath();
|
|
2373
|
+
ctx.moveTo(w * 0.25, h * 0.375);
|
|
2374
|
+
ctx.lineTo(w * 0.5, h * 0.65);
|
|
2375
|
+
ctx.lineTo(w * 0.75, h * 0.375);
|
|
2376
|
+
ctx.stroke(); // no strokeStyle: the ink is style.color
|
|
2377
|
+
};
|
|
2378
|
+
|
|
2379
|
+
<canvas
|
|
2380
|
+
mono
|
|
2381
|
+
cacheKey="chevron"
|
|
2382
|
+
onDraw={chevron}
|
|
2383
|
+
style={{
|
|
2384
|
+
width: 12,
|
|
2385
|
+
height: 12,
|
|
2386
|
+
color: '$textMuted',
|
|
2387
|
+
':disabled': { color: '$border' },
|
|
2388
|
+
}}
|
|
2389
|
+
/>;
|
|
2390
|
+
```
|
|
2391
|
+
|
|
2392
|
+
The promise is what lets one drawing serve every state a control puts it in:
|
|
2393
|
+
`:hover`, `:disabled` and a theme flip all reach the same `onDraw`, and none of
|
|
2394
|
+
them is the drawing's business.
|
|
2395
|
+
|
|
2396
|
+
**What it buys at the cache.** With a `cacheKey`, a `mono` drawing is kept as
|
|
2397
|
+
**coverage** rather than as pixels, with the colour applied at composite time —
|
|
2398
|
+
so the colour leaves the key and one rendered copy serves every ink. That is
|
|
2399
|
+
the trick the glyph cache runs on text, and the one `<svg>` gets for a
|
|
2400
|
+
`fill="currentColor"` document; a closure cannot be scanned the way a document
|
|
2401
|
+
can, so here you say it. Without `mono`, each colour of the same shape is a
|
|
2402
|
+
separate rasterization and a separate pixmap.
|
|
2403
|
+
|
|
2404
|
+
Needs **ntk ≥ 7.3.3**, which `package.json` carries: earlier versions
|
|
2405
|
+
composite coverage as empty under a clip they cannot express as a rectangle
|
|
2406
|
+
([sidorares/ntk#243](https://github.com/sidorares/ntk/issues/243)), and a
|
|
2407
|
+
widget sits inside clips like that routinely.
|
|
2408
|
+
|
|
2409
|
+
`mono` works without `cacheKey` (the ink is preset either way), but the two
|
|
2410
|
+
together are the point. The [system icon set](components.md#system-icons) is
|
|
2411
|
+
twelve of these.
|
|
2412
|
+
|
|
2413
|
+
---
|
|
2414
|
+
|
|
2415
|
+
## `<glarea>`
|
|
2416
|
+
|
|
2417
|
+
An OpenGL surface in the layout — on X11 the only drawn element that owns a
|
|
2418
|
+
real X window, because GLX needs a drawable created for a GL-capable visual
|
|
2419
|
+
and cannot share the XRender pipeline the rest of the tree paints through
|
|
2420
|
+
(NEXT_STEPS §4); the other backends give it a surface of their own
|
|
2421
|
+
([gl.md](gl.md)). On X11 under the default `glPolicy` it needs ntk ≥ 3.6.0
|
|
2422
|
+
and a server with **indirect GLX** enabled (`+iglx` / `AllowIndirectGLX` —
|
|
2423
|
+
off by default on many).
|
|
2424
|
+
|
|
2425
|
+
```jsx
|
|
2426
|
+
<glarea
|
|
2427
|
+
style={{ flexGrow: 1 }}
|
|
2428
|
+
clearColor="#0b1021"
|
|
2429
|
+
frameLoop="always"
|
|
2430
|
+
onCreated={(gl) => gl.Enable(gl.DEPTH_TEST)}
|
|
2431
|
+
onDraw={(gl, { width, height }) => {
|
|
2432
|
+
gl.MatrixMode(gl.PROJECTION);
|
|
2433
|
+
gl.LoadIdentity();
|
|
2434
|
+
gl.Frustum(-1, 1, -height / width, height / width, 2, 20);
|
|
2435
|
+
// ... immediate-mode or display-list drawing
|
|
2436
|
+
}}
|
|
2437
|
+
/>
|
|
2438
|
+
```
|
|
2439
|
+
|
|
2440
|
+
- `onDraw(gl, { width, height, node })` — draw one frame. The viewport is
|
|
2441
|
+
set and the buffers are cleared before it, `SwapBuffers` follows it.
|
|
2442
|
+
- `onCreated(gl, info)` — runs once, when the context is current: one-time
|
|
2443
|
+
GL state, texture uploads, display-list compilation.
|
|
2444
|
+
- `clearColor` — CSS colour or `[r, g, b, a]` floats (default black).
|
|
2445
|
+
- `frameLoop` — `'demand'` (default) redraws on prop, size and expose
|
|
2446
|
+
changes only; `'always'` renders continuously on ntk's frame clock.
|
|
2447
|
+
- `frameRate` — how those frames are paced when they are expensive: the
|
|
2448
|
+
same values as [`<window frameRate>`](#framerate--pacing-the-frames-under-a-flood),
|
|
2449
|
+
priced by what `onDraw` and the swap cost the JS thread. Defaults to the
|
|
2450
|
+
owning window's, so a `<window frameRate="adaptive">` paces the scene in
|
|
2451
|
+
it too; name `'display'` here for a scene that wants every frame while the
|
|
2452
|
+
window around it streams. A `frameLoop` of `'always'` under `'adaptive'`
|
|
2453
|
+
is a loop at the budget rather than at the display.
|
|
2454
|
+
- `glx` — a visual spec for ntk's `chooseGLXConfig`, e.g.
|
|
2455
|
+
`{ DEPTH_SIZE: 24 }`. One query per app, shared by every `<glarea>`.
|
|
2456
|
+
- `onError(err)` — no GL surface (no GLX, no matching visual). Without a
|
|
2457
|
+
handler the failure is a console warning.
|
|
2458
|
+
|
|
2459
|
+
On X11's indirect path, the default, `gl` is ntk's indirect-GLX context:
|
|
2460
|
+
fixed-function OpenGL 1.4 (immediate mode, matrices, lighting, textures,
|
|
2461
|
+
display lists) — no shaders, no vertex arrays, since the GLX protocol does
|
|
2462
|
+
not encode them. **Geometry belongs in display lists**: every
|
|
2463
|
+
immediate-mode vertex is a command on the wire, so a mesh re-sent per frame
|
|
2464
|
+
costs kilobytes per frame, while a compiled list costs one `CallList`. The
|
|
2465
|
+
direct path and the Cocoa backend hand `onDraw` a WebGL-shaped table with
|
|
2466
|
+
shaders instead ([gl.md](gl.md)).
|
|
2467
|
+
|
|
2468
|
+
The surface is stacked above everything drawn in its window, so nothing the
|
|
2469
|
+
window paints can overlap it. **Its own children can: they are 2D content
|
|
2470
|
+
drawn above the surface.** Layout treats a `<glarea>` as a flex container
|
|
2471
|
+
like a `<box>` — it is sized and positioned like any node, its window follows
|
|
2472
|
+
that rect, and its children are laid out inside it and cut to its box: a
|
|
2473
|
+
legend in a corner, a toolbar along an edge, a label over the scene. They are
|
|
2474
|
+
ordinary nodes in every way but one. The window's own paint cannot reach
|
|
2475
|
+
above the surface, so they are painted on **panes** stacked over it, in the
|
|
2476
|
+
same frame and from the same damage as everything else.
|
|
2477
|
+
|
|
2478
|
+
```jsx
|
|
2479
|
+
<glarea style={{ flexGrow: 1 }} clearColor="#0e1320" onDraw={drawMap}>
|
|
2480
|
+
<box
|
|
2481
|
+
style={{
|
|
2482
|
+
position: 'absolute',
|
|
2483
|
+
left: 12,
|
|
2484
|
+
top: 12,
|
|
2485
|
+
padding: 8,
|
|
2486
|
+
backgroundColor: '#f4f5f8',
|
|
2487
|
+
}}
|
|
2488
|
+
>
|
|
2489
|
+
<text>Legend</text>
|
|
2490
|
+
</box>
|
|
2491
|
+
</glarea>
|
|
2492
|
+
```
|
|
2493
|
+
|
|
2494
|
+
What a pane is depends on the backend, and so does what translucent means:
|
|
2495
|
+
|
|
2496
|
+
| | Cocoa | X11 |
|
|
2497
|
+
| -------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
2498
|
+
| a pane | one transparent layer over the whole surface, above the GL layer | one child window per child, as big as its paint reach, stacked directly above the surface's window in the children's order; where two overlap, the upper pane holds the lower child's pixels too |
|
|
2499
|
+
| translucency | composited by Core Animation: a translucent fill, an antialiased edge or a shadow blends with the frame | partly: a pane is cut with SHAPE to where its child painted half its alpha or more, and the frame shows everywhere else; a pixel inside the cut that is translucent — an antialiased edge, a translucent fill — blends with the `clearColor`, not the frame |
|
|
2500
|
+
| between the children | the GL frame | the GL frame |
|
|
2501
|
+
|
|
2502
|
+
So on X11 expect a translucent fill to be tinted by the `clearColor`
|
|
2503
|
+
rather than to show the scene: the frame shows around a child, not through
|
|
2504
|
+
it. X11 cannot do better without a compositor, and a compositor would not
|
|
2505
|
+
help: it blends top-level windows, not the children inside one. The cut
|
|
2506
|
+
needs the SHAPE extension, which every real X server has; without it — the
|
|
2507
|
+
in-process server in `react-x11/test` — a pane is the rectangle of its
|
|
2508
|
+
child's reach, and whatever the child leaves unpainted there shows the
|
|
2509
|
+
`clearColor`. A window per child keeps each pane as small as what it
|
|
2510
|
+
holds.
|
|
2511
|
+
|
|
2512
|
+
A pane is its child's, and holds that child and what is under it, never
|
|
2513
|
+
what is over it. So a child that moves — a card dragged across a graph —
|
|
2514
|
+
takes its pane along and repaints nothing when nothing is under it, and the
|
|
2515
|
+
panes it passes over are not touched at all. A pane's window takes its new
|
|
2516
|
+
place and the pixels that go there in one batch of requests, and the
|
|
2517
|
+
server keeps what each pane and the surface show while they are mapped
|
|
2518
|
+
(backing store), so what a moving pane uncovers is back at once rather than
|
|
2519
|
+
after an Expose and a repaint.
|
|
2520
|
+
|
|
2521
|
+
`useSupports('glOverlay')` says whether a connection draws a surface's
|
|
2522
|
+
children at all. Every backend does, on every display but one.
|
|
2523
|
+
|
|
2524
|
+
**XQuartz draws nothing over a GL surface.** There every surface, direct
|
|
2525
|
+
(Apple-DRI) or indirect, is composited by the macOS window server above
|
|
2526
|
+
everything the X server draws in the window. A pane stacked over the
|
|
2527
|
+
surface in X is drawn under it on screen, and nothing on the X side changes
|
|
2528
|
+
that: cutting the surface's window down with SHAPE still leaves the frame
|
|
2529
|
+
covering all of it. A pane over the _whole_ surface does show, but only
|
|
2530
|
+
because the frame then disappears. So on XQuartz the children are laid out
|
|
2531
|
+
and not drawn, the pointer over them is the surface's,
|
|
2532
|
+
`useSupports('glOverlay')` is false, and a development build says so once.
|
|
2533
|
+
Ask the hook and draw the overlay another way there, beside the surface or
|
|
2534
|
+
in GL, or run on the Cocoa backend (the default on macOS), which composites
|
|
2535
|
+
it.
|
|
2536
|
+
|
|
2537
|
+
**A child that only moves is not repainted.** A pane keeps what it drew
|
|
2538
|
+
between frames, so when all that changed about one of the surface's children
|
|
2539
|
+
is where it sits — its `left` or `top`, and nothing inside it — the frame
|
|
2540
|
+
moves that child's pixels on the pane and repaints only what the copy cannot
|
|
2541
|
+
supply: the strips the move uncovered, and the other children's pixels it
|
|
2542
|
+
passed over or under. A layer of mounted content panned across a scene,
|
|
2543
|
+
such as a graph's node bodies over its GL edges, then costs a copy and a few
|
|
2544
|
+
strips a frame rather than a repaint of everything on show. A scroll pane
|
|
2545
|
+
among the children scrolls the same way, moving the pane's pixels. A child
|
|
2546
|
+
that also changed size, or drew anything differently in the same commit, is
|
|
2547
|
+
repainted as before, and
|
|
2548
|
+
[`REACT_X11_NO_SCROLL_BLIT=1`](debugging.md#react_x11_no_scroll_blit1) turns
|
|
2549
|
+
every such copy off.
|
|
2550
|
+
|
|
2551
|
+
Limits worth knowing:
|
|
2552
|
+
|
|
2553
|
+
- **Overlapping surfaces:** where two surfaces overlap, both overlays are
|
|
2554
|
+
above both frames on the Cocoa backend, and stack by when each was made
|
|
2555
|
+
on X11.
|
|
2556
|
+
- **Scroll panes:** a surface scrolled partly out of a scroll pane is not
|
|
2557
|
+
clipped by it. Its window and its panes are real windows placed at its
|
|
2558
|
+
rect.
|
|
2559
|
+
- **Promotion:** a child of the surface is never lifted onto a layer of its
|
|
2560
|
+
own for an animation on the Cocoa backend. That layer would sit under the
|
|
2561
|
+
surface. Its transitions run on the frame clock instead.
|
|
2562
|
+
|
|
2563
|
+
**The pointer over the surface is the tree's**, on every backend, exactly
|
|
2564
|
+
as over a `<box>`: `onMouseDown`, `onMouseMove`, `onMouseUp`, `onClick` (a
|
|
2565
|
+
double click is `detail: 2`), `onContextMenu`, `onMouseEnter` and
|
|
2566
|
+
`onMouseLeave`, `:hover` and `:active`, and `onWheel` fire at the child
|
|
2567
|
+
under the pointer, or at the `<glarea>` between its children, and bubble to
|
|
2568
|
+
the ancestors. A press focuses the nearest focusable one, and
|
|
2569
|
+
`ev.capturePointer()` keeps a drag that wanders off the surface. The
|
|
2570
|
+
window's hit test knows the surface and its children are on top: a point
|
|
2571
|
+
inside the rect lands on them, not on whatever the tree has behind.
|
|
2572
|
+
`pointerEvents: 'none'` on the surface lets the pointer through to that
|
|
2573
|
+
instead.
|
|
2574
|
+
|
|
2575
|
+
```jsx
|
|
2576
|
+
<glarea
|
|
2577
|
+
onMouseDown={(ev) => {
|
|
2578
|
+
ev.capturePointer();
|
|
2579
|
+
orbit.start(ev.x, ev.y);
|
|
2580
|
+
}}
|
|
2581
|
+
onMouseMove={(ev) => orbit.drag(ev.x, ev.y)}
|
|
2582
|
+
onWheel={(ev) => zoom(ev.deltaY)}
|
|
2583
|
+
/>
|
|
2584
|
+
```
|
|
2585
|
+
|
|
2586
|
+
How it gets there differs underneath and nowhere above. On X11 the surface
|
|
2587
|
+
is a child window that **selects no input of its own**, so the server
|
|
2588
|
+
reports what happens over it to the window the tree lives in — in that
|
|
2589
|
+
window's coordinates, under that window's grab, and with its XI2 selection,
|
|
2590
|
+
so the wheel over a surface is the same wheel as anywhere else in the
|
|
2591
|
+
window. On the Cocoa backend the surface is a layer and the events were the
|
|
2592
|
+
window's all along.
|
|
2593
|
+
|
|
2594
|
+
One rule follows for an element built on `<glarea>`: **do not listen for
|
|
2595
|
+
the pointer on `node.window`**. ntk selects whatever its window is listened
|
|
2596
|
+
to for, and X then delivers the event there instead of to the tree — every
|
|
2597
|
+
press, and the wheel with them, since both are ButtonPress. Listening there
|
|
2598
|
+
was the only way to hear a press over a surface before core delivered it;
|
|
2599
|
+
`node.forwardsPointer` is `true` where it does
|
|
2600
|
+
(`GlAreaNode.prototype.forwardsPointer` from `react-x11/node`, to ask
|
|
2601
|
+
without rendering), and is the switch for dropping such a listener.
|
|
2602
|
+
|
|
2603
|
+
`onDraw` is the raw escape hatch, and a scene graph over it is
|
|
2604
|
+
`@react-x11/components/three`. See `examples/viewer3d.jsx` for the raw
|
|
2605
|
+
form, and `examples/labs/gl-overlay.jsx` for a surface with a HUD over it on
|
|
2606
|
+
both backends, and beside it on XQuartz.
|
|
2607
|
+
|
|
2608
|
+
---
|
|
2609
|
+
|
|
2610
|
+
## `<foreign>`
|
|
2611
|
+
|
|
2612
|
+
**X11 only.** Cross-process window embedding does not exist on macOS —
|
|
2613
|
+
there is no equivalent of handing another application's window to your
|
|
2614
|
+
layout, and none is planned. Where there is none — the Cocoa, Wayland and
|
|
2615
|
+
Windows backends, and the headless mock tests render on — this element
|
|
2616
|
+
refuses: it lays out as an empty box and draws nothing, never calls
|
|
2617
|
+
`onReady`, and calls `onError` once to say why. Ask before rendering one:
|
|
2618
|
+
|
|
2619
|
+
```jsx
|
|
2620
|
+
const embedding = useSupports('embedding');
|
|
2621
|
+
```
|
|
2622
|
+
|
|
2623
|
+
`<Frame>`, which uses it on X11 to host a pane of your _own_ application,
|
|
2624
|
+
does work on the Cocoa and Windows backends too ([frame.md](frame.md)) — it
|
|
2625
|
+
just gets there another way.
|
|
2626
|
+
|
|
2627
|
+
Another process's top-level X window, laid out as an element — a terminal
|
|
2628
|
+
pane, a video surface, a docked tray icon. The second element that owns a
|
|
2629
|
+
real X window without painting into its parent, and the only one that makes
|
|
2630
|
+
a react-x11 app a _host_ rather than a drawer of its own pixels. Needs ntk
|
|
2631
|
+
≥ 7.4.0.
|
|
2632
|
+
|
|
2633
|
+
```jsx
|
|
2634
|
+
<foreign
|
|
2635
|
+
windowId={terminal.windowId}
|
|
2636
|
+
style={{ flexGrow: 1, backgroundColor: '#101014' }}
|
|
2637
|
+
onEmbedded={({ xembed }) => setMode(xembed ? 'xembed' : 'reparented')}
|
|
2638
|
+
onClientGone={() => respawn()}
|
|
2639
|
+
/>
|
|
2640
|
+
```
|
|
2641
|
+
|
|
2642
|
+
- `windowId` — the window to embed. Changing it hands the old client back
|
|
2643
|
+
before the new one is taken. **Omit it** to adopt whatever is put inside
|
|
2644
|
+
this node instead, which is what `xterm -into WID` and `mpv --wid=WID`
|
|
2645
|
+
need.
|
|
2646
|
+
- `onReady({ windowId })` — this node's own container id, offered before
|
|
2647
|
+
anything is embedded, so a program can be spawned into it. Never called
|
|
2648
|
+
where there is no embedding, so the id is always a real window's.
|
|
2649
|
+
- `onEmbedded({ id, xembed, version })` — a client is in. `xembed: false`
|
|
2650
|
+
is a client that set no `_XEMBED_INFO` and got plain reparenting, which
|
|
2651
|
+
is the common case rather than the fallback.
|
|
2652
|
+
- `onClientGone()` — destroyed, or reparented away by someone else.
|
|
2653
|
+
- `onRequestFocus()` — the client asked for the focus. It is then given
|
|
2654
|
+
through the focus manager, so the rest of the tree observes it normally.
|
|
2655
|
+
- `onError(err)` — the embed failed, or — once, at mount — this backend has
|
|
2656
|
+
no embedding at all. Without a handler, a console warning; for the second
|
|
2657
|
+
kind, one per app however many `<foreign>`s ask.
|
|
2658
|
+
- `focusable` — default `true`, like `<textinput>`.
|
|
2659
|
+
- `backgroundColor` in the `style` is what shows in the rect with no client
|
|
2660
|
+
in it: the container window's background, painted by the server.
|
|
2661
|
+
|
|
2662
|
+
**Unmount reparents the client back to the root and drops it from the save
|
|
2663
|
+
set. It never destroys it** — a React tree changing shape is not a reason
|
|
2664
|
+
for another application to lose its window.
|
|
2665
|
+
|
|
2666
|
+
Layout treats it as a leaf, and its X window follows that rect; every change
|
|
2667
|
+
also sends the client the synthetic ICCCM 4.1.5 `ConfigureNotify` with
|
|
2668
|
+
root-relative coordinates. Stacking is `<glarea>`'s: the child window sits
|
|
2669
|
+
above everything drawn in the parent, so 2D content cannot overlap it — and
|
|
2670
|
+
unlike a `<glarea>`, `<foreign>` takes no children to draw over it, so an
|
|
2671
|
+
overlay belongs in a sibling `<popup>` — and pointer events over it are the
|
|
2672
|
+
client's.
|
|
2673
|
+
|
|
2674
|
+
Keyboard focus is where this element has a rule of its own: **while a
|
|
2675
|
+
`<foreign>` holds focus, the application's handlers see every key first and
|
|
2676
|
+
anything they do not consume with `preventDefault()` is forwarded to the
|
|
2677
|
+
client.** That, the XEmbed focus messages, the tab chain crossing the
|
|
2678
|
+
boundary and the one case the rule cannot cover are all in
|
|
2679
|
+
[embedding.md](embedding.md).
|
|
2680
|
+
|
|
2681
|
+
---
|
|
2682
|
+
|
|
2683
|
+
## 3D scene: `<mesh>`, `<group>`, geometries, materials
|
|
2684
|
+
|
|
2685
|
+
Moved to [`@react-x11/components/three`](ecosystem.md), which is a scene
|
|
2686
|
+
graph with its own reconciler over the `<glarea>` above — and, unlike the
|
|
2687
|
+
version that lived here, one that renders on **both** GL backends from the
|
|
2688
|
+
same JSX. Core keeps the surface and `onDraw`; see [gl.md](gl.md).
|
|
2689
|
+
|
|
2690
|
+
## Vector drawings
|
|
2691
|
+
|
|
2692
|
+
`<svg>` is a drawing, laid out like an `<image>`: it reports an intrinsic
|
|
2693
|
+
size and scales into the content box it is given.
|
|
2694
|
+
|
|
2695
|
+
Documents used to live here too — `<markdown>`, `<html>` and `<tex>`, thin
|
|
2696
|
+
wrappers over ntk's document widgets. They were removed in react-x11 2.0:
|
|
2697
|
+
each rendered a whole document as one opaque drawing, which foreclosed the
|
|
2698
|
+
two things a document most needs — text selection across blocks, and
|
|
2699
|
+
re-rendering only the block that changed while content streams in. Their
|
|
2700
|
+
successors are components in
|
|
2701
|
+
[`@react-x11/components`](https://github.com/sidorares/react-x11-components),
|
|
2702
|
+
composed from public host elements rather than wrapping a widget:
|
|
2703
|
+
`<Markdown>` (GFM, selectable, streaming-friendly) and `<Formula>` (KaTeX,
|
|
2704
|
+
selectable), with `<RichText>` and `<CodeBlock>` underneath them.
|
|
2705
|
+
|
|
2706
|
+
### `<svg>`
|
|
2707
|
+
|
|
2708
|
+
ntk `SvgView` (static SVG via Path2D — paths, shapes, gradients,
|
|
2709
|
+
transforms, basic text). Sized like `<image>`, and by the same style
|
|
2710
|
+
properties: natural `viewBox` size, aspect-preserving when the style sets
|
|
2711
|
+
only one of `style={{ width, height }}`, and scaled into the content box
|
|
2712
|
+
when it sets both.
|
|
2713
|
+
|
|
2714
|
+
Content is **JSX children, like SVG in React DOM** — SVG elements are
|
|
2715
|
+
declarative children with camelCase props (`strokeWidth`, `fillRule`;
|
|
2716
|
+
native-camelCase attributes like `viewBox` stay as-is), re-rendered on
|
|
2717
|
+
any prop change:
|
|
2718
|
+
|
|
2719
|
+
```jsx
|
|
2720
|
+
<svg viewBox="0 0 24 24" style={{ width: 40, height: 40 }}>
|
|
2721
|
+
<circle cx={12} cy={12} r={10} fill={active ? '#2980b9' : '#ccc'} />
|
|
2722
|
+
<path d="M8 12l3 3 5-6" stroke="white" strokeWidth={2} fill="none" />
|
|
2723
|
+
</svg>
|
|
2724
|
+
```
|
|
2725
|
+
|
|
2726
|
+
A `source` markup string is also accepted (children win when both are
|
|
2727
|
+
present). Supported elements/attributes are SvgView's (unsupported tags
|
|
2728
|
+
are skipped); per-child event handlers are not dispatched — put handlers
|
|
2729
|
+
on the `<svg>` element itself.
|
|
2730
|
+
|
|
2731
|
+
| prop | |
|
|
2732
|
+
| --------- | --------------------------------- |
|
|
2733
|
+
| children | declarative SVG elements |
|
|
2734
|
+
| `source` | SVG markup string |
|
|
2735
|
+
| `viewBox` | coordinate system (children form) |
|
|
2736
|
+
|
|
2737
|
+
**`currentColor` and recolouring.** `fill="currentColor"` and
|
|
2738
|
+
`stroke="currentColor"` resolve the way the word does in CSS: the node's own
|
|
2739
|
+
`color` if it named one, otherwise the ink it inherits, and under that the
|
|
2740
|
+
palette's `text`
|
|
2741
|
+
([styling.md](styling.md#inheritance-the-ink-the-face-and-the-size)). So one
|
|
2742
|
+
drawing serves every state the UI puts it in, and an unstyled one is
|
|
2743
|
+
readable on a dark desktop rather than black on it:
|
|
2744
|
+
|
|
2745
|
+
```jsx
|
|
2746
|
+
<svg
|
|
2747
|
+
source={icons.gauge}
|
|
2748
|
+
style={{
|
|
2749
|
+
width: 20,
|
|
2750
|
+
height: 20,
|
|
2751
|
+
color: '$fg',
|
|
2752
|
+
':hover': { color: '$accent' },
|
|
2753
|
+
}}
|
|
2754
|
+
/>
|
|
2755
|
+
```
|
|
2756
|
+
|
|
2757
|
+
A drawing whose paint is entirely `currentColor` — or entirely one colour — is
|
|
2758
|
+
cached as coverage rather than as pixels, so recolouring it is a composite and
|
|
2759
|
+
not a re-render, and every colour of it shares one rendered copy. Drawings with
|
|
2760
|
+
two colours or a gradient bake their colours in, which is right: those colours
|
|
2761
|
+
belong to the drawing rather than to the UI. Nothing to configure either way.
|
|
2762
|
+
`REACT_X11_DEBUG_PAINT_CACHE=1` shows what is being kept.
|