react-x11 2.51.1 → 2.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/README.md +94 -61
  2. package/docs/README.md +256 -0
  3. package/docs/accessibility.md +575 -0
  4. package/docs/appearance.md +317 -0
  5. package/docs/click-to-component.md +92 -0
  6. package/docs/clipboard.md +262 -0
  7. package/docs/components.md +1231 -0
  8. package/docs/dbus.md +205 -0
  9. package/docs/debugging.md +371 -0
  10. package/docs/desktop-calendar.md +247 -0
  11. package/docs/desktop.md +523 -0
  12. package/docs/devtools.md +133 -0
  13. package/docs/drag-and-drop.md +585 -0
  14. package/docs/ecosystem/animation.md +360 -0
  15. package/docs/ecosystem/data-fetching.md +186 -0
  16. package/docs/ecosystem/dev-tooling.md +393 -0
  17. package/docs/ecosystem/forms.md +277 -0
  18. package/docs/ecosystem/headless.md +194 -0
  19. package/docs/ecosystem/i18n.md +86 -0
  20. package/docs/ecosystem/icons.md +377 -0
  21. package/docs/ecosystem/layout.md +206 -0
  22. package/docs/ecosystem/routing.md +135 -0
  23. package/docs/ecosystem/state.md +344 -0
  24. package/docs/ecosystem/testing.md +474 -0
  25. package/docs/ecosystem/theming.md +494 -0
  26. package/docs/ecosystem.md +439 -0
  27. package/docs/elements.md +2079 -0
  28. package/docs/embedding.md +239 -0
  29. package/docs/events.md +742 -0
  30. package/docs/extending.md +1700 -0
  31. package/docs/eyedropper.md +164 -0
  32. package/docs/filedialog.md +222 -0
  33. package/docs/frame.md +436 -0
  34. package/docs/gl.md +173 -0
  35. package/docs/globalmenu.md +195 -0
  36. package/docs/macos.md +592 -0
  37. package/docs/notifications.md +150 -0
  38. package/docs/packaging.md +468 -0
  39. package/docs/permissions.md +143 -0
  40. package/docs/react-features.md +405 -0
  41. package/docs/remote.md +179 -0
  42. package/docs/scale.md +225 -0
  43. package/docs/security.md +132 -0
  44. package/docs/styling.md +1710 -0
  45. package/docs/system.md +519 -0
  46. package/docs/testing.md +494 -0
  47. package/docs/typescript.md +75 -0
  48. package/docs/uri-schemes.md +340 -0
  49. package/docs/wayland-backend.md +332 -0
  50. package/docs/windows-integrations.md +177 -0
  51. package/docs/windows.md +380 -0
  52. package/package.json +11 -3
  53. package/src/Reconciler.js +2 -2
  54. package/src/a11y.js +1 -1
  55. package/src/backend/context2d.js +3 -3
  56. package/src/bootstrap.js +1 -1
  57. package/src/bus.js +39 -26
  58. package/src/cli/create.d.ts +43 -0
  59. package/src/cli/create.js +305 -0
  60. package/src/cli/env.js +484 -0
  61. package/src/cli/index.js +29 -2
  62. package/src/cli/templates/AGENTS.template.md +195 -0
  63. package/src/cli/templates/DESIGN.template.md +269 -0
  64. package/src/cocoa/app.js +6 -6
  65. package/src/cocoa/bezels.js +1 -1
  66. package/src/cocoa/fonts.js +1 -1
  67. package/src/cocoa/globalmenu.js +1 -1
  68. package/src/cocoa/keymap.js +1 -1
  69. package/src/cocoa/panewindow.js +1 -1
  70. package/src/cocoa/player.js +1 -1
  71. package/src/cocoa/presenter.js +2 -2
  72. package/src/cocoa/promotion.js +1 -1
  73. package/src/cocoa/relaunch.js +1 -1
  74. package/src/cocoa/threaded.js +1 -1
  75. package/src/cocoa/video.js +1 -1
  76. package/src/cocoa/window.js +4 -4
  77. package/src/components/Menu.js +7 -2
  78. package/src/components/Switch.js +1 -1
  79. package/src/components/native.js +1 -1
  80. package/src/desktopintegration.js +2 -1
  81. package/src/desktopsettings.js +193 -24
  82. package/src/dnd.js +2 -2
  83. package/src/filedialog.js +3 -3
  84. package/src/glnodes.js +1 -1
  85. package/src/grid.js +1 -1
  86. package/src/keyboardstate.js +28 -0
  87. package/src/layouts.js +1 -1
  88. package/src/node.d.ts +10 -10
  89. package/src/nodes/animation.js +1 -1
  90. package/src/nodes/invalidate.js +1 -1
  91. package/src/nodes/layouthost.js +1 -1
  92. package/src/nodes/video.js +19 -8
  93. package/src/nodes/window/droptarget.js +1 -1
  94. package/src/pacing.js +1 -1
  95. package/src/portal.js +31 -0
  96. package/src/refresh/index.js +2 -1
  97. package/src/screencolor.js +5 -10
  98. package/src/types/system.d.ts +8 -6
  99. package/src/types/video.d.ts +5 -2
  100. package/src/videobackend.js +28 -0
  101. package/src/videoframes.js +29 -16
  102. package/src/wayland/app.js +86 -0
  103. package/src/wayland/clipboard.js +14 -7
  104. package/src/wayland/connection.js +3 -0
  105. package/src/wayland/context2d.js +129 -10
  106. package/src/wayland/protocols/index.json +6 -0
  107. package/src/wayland/protocols/xdg-foreign-unstable-v2.json +1 -0
  108. package/src/wayland/seat.js +28 -1
  109. package/src/wayland/swapchain.js +23 -0
  110. package/src/wayland/video.js +376 -0
  111. package/src/wayland/xkb.js +137 -0
  112. package/src/win32/a11y.js +1 -1
  113. package/src/win32/app.js +2 -2
  114. package/src/win32/fonts.js +1 -1
  115. package/src/win32/glarea.js +4 -4
  116. package/src/win32/overlay.js +1 -1
  117. package/src/win32/panewindow.js +3 -3
  118. package/src/win32/shell.js +1 -1
  119. package/src/win32/window.js +2 -2
  120. package/src/windowid.js +1 -1
  121. package/src/xvideo.js +189 -0
@@ -0,0 +1,2079 @@
1
+ # Elements
2
+
3
+ Only `<window>`, `<popup>`, `<glarea>` and `<foreign>` are backed by real
4
+ windows of the display system. Everything else is a retained lightweight
5
+ node — one [yoga-layout](https://www.yogalayout.dev/) node each — painted
6
+ into the owning window's double-buffered 2d context on ntk's frame clock.
7
+
8
+ ## `style` and props
9
+
10
+ Everything CSS has a concept for goes in **`style`**; everything else is a
11
+ prop. No name means both, which is why `<window width>` is unambiguously the
12
+ real window's geometry. See [styling.md](styling.md) for arrays, state
13
+ blocks and `createStyles`.
14
+
15
+ ```jsx
16
+ <box style={{ flexDirection: 'row', gap: 8 }} onClick={pick}>
17
+ <text style={{ fontSize: 14, color: '#2d3436' }}>hello</text>
18
+ </box>
19
+ ```
20
+
21
+ ## Layout properties (all drawn elements + windows)
22
+
23
+ Lengths are logical pixels — a number, or `'12px'` — and `'50%'` / `'auto'`
24
+ pass through to yoga where it takes them. `em`, `rem`,
25
+ `vw`/`vh`/`vmin`/`vmax` and `calc()`/`min()`/`max()`/`clamp()` resolve to
26
+ pixels for their node, and again when what they are relative to changes; a
27
+ CSS unit react-x11 does not read (`ex`, `cqw`, `pt`) is an error naming it.
28
+ CSS's shorthands are read too — `padding: '8px 16px'`,
29
+ `border: '1px solid $border'`, `inset: 0`
30
+ ([styling.md](styling.md#lengths-and-shorthands)).
31
+
32
+ - **Size**: `width`, `height`, `minWidth`, `minHeight`, `maxWidth`,
33
+ `maxHeight`, `aspectRatio`. `<image>` and `<svg>` keep their aspect ratio
34
+ when only one of `width`/`height` is given
35
+ - **Flex**: `flexDirection` (`row`, `column`, `row-reverse`,
36
+ `column-reverse`), `justifyContent` (`flex-start`, `center`, `flex-end`,
37
+ `space-between`, `space-around`, `space-evenly`), `alignItems`,
38
+ `alignSelf`, `alignContent`, `flexWrap`, `flex`, `flexGrow`, `flexShrink`,
39
+ `flexBasis`, `gap`, `rowGap`, `columnGap`
40
+ - **Position**: `position` (`relative`, `absolute`, `static`, `sticky`, or
41
+ one registered with `registerPosition`), `top`, `right`, `bottom`,
42
+ `left`, `start`, `end`, and `inset` for all four. Under `sticky` the
43
+ insets are how close to the nearest scroll pane's edges the box may come,
44
+ not offsets — see [styling.md](styling.md#sticky-positioning) and
45
+ [custom positions](styling.md#custom-positions)
46
+ - **Grid**: `display: 'grid'` arranges the children as a CSS grid —
47
+ `gridTemplateColumns`, `gridTemplateRows`, `gridTemplateAreas`,
48
+ `gridAutoColumns`, `gridAutoRows`, `gridAutoFlow` and `justifyItems` on
49
+ the box, `gridColumn`, `gridRow`, `gridArea` and `justifySelf` on a child
50
+ — see [styling.md](styling.md#grid)
51
+ - **Arrangement**: `layout` (`grid`, `masonry`, `equal-row`, or one
52
+ registered with `registerLayout`) arranges the children in place of
53
+ flexbox, and a child's `layoutItem` is what it tells that layout — see
54
+ [styling.md](styling.md#custom-layouts)
55
+ - **Spacing**: `margin`, `marginTop/Right/Bottom/Left`, `padding`,
56
+ `paddingTop/Right/Bottom/Left`, and the logical pairs `paddingInline`,
57
+ `paddingBlock`, `marginInline`, `marginBlock`. `margin` and `padding` take
58
+ CSS's one-to-four values as one string: `'0 auto'`, `'8px 16px'`
59
+ - **Visibility**: `display` (`flex`, `grid`, `none`), `overflow` (`visible`,
60
+ `hidden`, `scroll`)
61
+
62
+ ### Everything shrinks, nothing shrinks to nothing
63
+
64
+ `flexShrink` is `1`, as in CSS, but **an item never shrinks below what is
65
+ inside it**: every item has a floor of its own min-content size (CSS's
66
+ `min-width: auto` on a flex item). A row of chips squeezes until it wraps; a
67
+ label squeezes to its longest word; a `height: 40` row stays 40 tall. To say
68
+ otherwise:
69
+
70
+ | write | to mean |
71
+ | --------------------------------- | ----------------------------------------- |
72
+ | `flexShrink: 0` | never give up any space at all |
73
+ | `minWidth: 0` / `minHeight: 0` | this may shrink past its content, to zero |
74
+ | `overflow: 'hidden'` / `'scroll'` | the same, and clip what no longer fits |
75
+
76
+ The last two are CSS's own rule, and why a `<box overflow="scroll">` — a
77
+ viewport smaller than its content — gets `minWidth: 0` and `minHeight: 0`
78
+ for free.
79
+
80
+ One deliberate difference from CSS: **a size that was named is kept**. CSS
81
+ floors an item at `min(the size it was given, its content)`, so an empty
82
+ `height: 40` box squashes to nothing in a column too short for it; here it
83
+ stays 40. Say `minHeight: 0` to get CSS's answer.
84
+
85
+ ### `flex`, the shorthand
86
+
87
+ `flex: 1` is `{ flexGrow: 1, flexShrink: 1, flexBasis: 0 }` — "take this
88
+ share of what is left, from a base size of nothing" — the right thing for a
89
+ pane that should fill its window rather than its content. `flex: 'auto'`
90
+ grows and shrinks from the content's own size, and `flex: 'none'` does
91
+ neither. CSS's long form reads too — `'1 1 0'`, `'0 0 240px'`,
92
+ `'2 200px'`. A longhand written beside it wins, so
93
+ `{ flex: 1, flexBasis: 'auto' }` reads the way it does in CSS.
94
+
95
+ ## Paint properties
96
+
97
+ - `backgroundColor` — any CSS color string (`'#2980b9'`,
98
+ `'rgba(0,0,0,.5)'`, `'red'`); `background` sets it and a gradient
99
+ (`backgroundImage`) in one
100
+ - any colour may be `'currentColor'`, the node's ink — its own `color` or
101
+ the one it inherits, followed when it moves
102
+ ([styling.md](styling.md#currentcolor))
103
+ - `borderWidth`, `borderColor`, `borderRadius`, `borderStyle`
104
+ (`'solid'` default, `'dashed'`), or `border: '1px solid $border'` for the
105
+ first three — `border: '1px solid'` is drawn in `currentColor`
106
+ - per-side borders: `borderTopWidth`/`Right`/`Bottom`/`Left` override
107
+ `borderWidth`, `borderTopColor`/… fall back to `borderColor`, and
108
+ `borderBottom: '1px solid $border'` sets one side's pair — a blockquote's
109
+ bar is `borderLeftWidth: 3` on the quote box, a table rule
110
+ `borderBottomWidth: 1` on the row. `borderRadius` requires uniform
111
+ borders; a non-uniform border paints square
112
+ ([styling.md](styling.md#per-side-borders))
113
+ - `zIndex` — paint/hit order among siblings (stable sort)
114
+ - `objectFit` — where an `<image>`'s or a `<video>`'s picture goes in its
115
+ content box, CSS's `object-fit`, centred: `'fill'` (`<image>`'s default),
116
+ `'contain'` (`<video>`'s), `'cover'`, `'none'`, `'scale-down'`
117
+ - `outlineWidth`, `outlineColor`, `outlineOffset` — the focus ring, painted
118
+ outside the border box and invisible to yoga, so it never moves what it
119
+ surrounds. A focusable node draws one on `:focus-visible` unasked;
120
+ `outlineWidth: 0` — or `outline: 'none'` — opts out
121
+ ([styling.md](styling.md#the-focus-ring))
122
+ - `opacity` — how opaque the node is **with everything inside it**, 0 to 1:
123
+ the subtree is drawn once and composited at that alpha, so a card, its
124
+ icon and its text fade as one. `0` draws nothing and is still hit
125
+ ([styling.md](styling.md#opacity))
126
+ - `transition` — `120`, or `{ backgroundColor: 120, left: 200 }`: how long a
127
+ change to that property takes ([styling.md](styling.md#transitions))
128
+ - any value may be a **theme token**: `'$panel'` resolves against the nearest
129
+ `theme` prop above the node ([styling.md](styling.md#theme-tokens))
130
+ - `'@width >= 600'` blocks restyle for the window's size, layout included
131
+ ([styling.md](styling.md#window-size-queries))
132
+
133
+ `cursor` (`'pointer'`, `'text'`, `'wait'`, `'move'`, `'crosshair'`, resize
134
+ arrows, … — ntk's cursor names) and `pointerEvents` are style too.
135
+ `pointerEvents: 'none'` takes a node and its subtree out of hit testing;
136
+ `'box-none'` (React Native's) takes out only the node itself — its children
137
+ still take the pointer, and a press on its own area goes to what is behind:
138
+ the value for an overlay of controls, a `<glarea>`'s children included, that
139
+ must not swallow the clicks between them. So is `hitSlop`:
140
+
141
+ ```jsx
142
+ <box style={{ height: 16, hitSlop: { top: 4, bottom: 4 } }} /> // 24px target
143
+ ```
144
+
145
+ `hitSlop: 4` grows every side, the object form only the sides it names. It
146
+ affects **hit testing only** — not paint, not layout — so a 16px control
147
+ can take the 24px target WCAG 2.2 SC 2.5.8 asks for without misaligning its
148
+ row. Where slop overlaps a sibling, the one painted on top wins.
149
+
150
+ ## Interaction props
151
+
152
+ `focusable`, `tabIndex` (sequential focus order; `-1` is focusable but not
153
+ tabbable), `autoFocus`, `trapFocus` (own a focus scope — Tab and presses
154
+ stay inside it, focus is restored when it unmounts), `disabled` (never
155
+ focusable, and the trigger for a `:disabled` style block), and the event
156
+ handlers listed in [events.md](events.md).
157
+
158
+ `onLayout` reports layout: `{x, y, width, height}` in logical pixels, the
159
+ position within the parent _as laid out_ (a scroll does not fire it), after
160
+ the first layout and then on each change. It arrives after the pass, so
161
+ setting state from it is safe. Use it when the reaction to a size is not a
162
+ style ([react-features.md](react-features.md#measuring-a-node)); when it
163
+ is, a `'@container'` block answers in the same frame
164
+ ([styling.md](styling.md#container-queries)).
165
+
166
+ `role` and the `aria-*` props are read by the built-in AT-SPI bridge so
167
+ screen readers see the tree; inert where no assistive technology listens,
168
+ with sensible defaults per element ([accessibility.md](accessibility.md)).
169
+
170
+ `dropAccept` + `onDrop` accept a drop and `draggable` + `dragData` start
171
+ one, with other applications as well as inside the app; the props alone
172
+ register the node ([drag-and-drop.md](drag-and-drop.md)).
173
+
174
+ `selectable` makes an element a **selection surface** — see
175
+ [Selecting text](#selecting-text).
176
+
177
+ `scale` zooms a subtree like CSS `zoom`, not a transform: every length under
178
+ the element, and its own style, is multiplied, and text is shaped at the
179
+ drawn size — what an element owning a viewport (a graph pane, a map) mounts
180
+ its content inside. Windows take no `scale`
181
+ ([scale.md](scale.md#a-subtree-of-its-own)).
182
+
183
+ ---
184
+
185
+ ## `<window>`
186
+
187
+ A real toplevel window — an X11 window, or an `NSWindow` on the Cocoa
188
+ backend; the flex, paint and event root for its subtree.
189
+
190
+ | prop | |
191
+ | --------------------------- | ------------------------------------------------------------------------------------------------------------- |
192
+ | `title` | window title (UTF-8, `WM_NAME` + `_NET_WM_NAME`) |
193
+ | `width`, `height`, `x`, `y` | geometry, not yoga style; a size is pixels or `'auto'` (default, [below](#natural-size)) |
194
+ | `backgroundColor` | full-window clear color (default white) |
195
+ | `onResize(ev)` | the geometry changed; the tree reflows itself. Fires for **moves** too |
196
+ | `onExpose(ev)` | after a repaint was required |
197
+ | `onCloseRequest(ev)` | WM close button; replaces the default answer |
198
+ | `states` | EWMH `_NET_WM_STATE`, controlled |
199
+ | `fullscreen`, `alwaysOnTop` | boolean sugar for two of those states |
200
+ | `decorations` | `false` asks the WM for no titlebar or border |
201
+ | `transparent` | 32-bit ARGB visual: rounded, translucent windows |
202
+ | `transientFor` | ICCCM `WM_TRANSIENT_FOR`: the window this one belongs to |
203
+ | `onStatesChange(states)` | what the window manager actually did |
204
+ | `onClientMessage(ev)` | a ClientMessage addressed to this window — EWMH, XEmbed, the tray |
205
+ | `theme` | palette `$token` style values resolve against, for this subtree |
206
+ | `embeddable` | never maps itself; an embedder ([`<foreign>`](embedding.md)) does. Set by a [`<Frame>`](frame.md) pane's root |
207
+ | `hidden` | realized and laid out, never mapped while true |
208
+ | `frameRate` | frame pacing: `'display'` (default), `'adaptive'`, `'throughput'`, fps, `{ budget, minFps, maxFps }` |
209
+
210
+ Each is described below. A geometry change sends only what changed, so a
211
+ new size leaves the window where the window manager or the user put it.
212
+ Windows may be nested (real X11 child windows). **Ref**: the live window —
213
+ on X11 the ntk `Window`, with `getContext('2d')`, `requestAnimationFrame`,
214
+ `setCursor` and the rest of the ntk API.
215
+
216
+ #### On the Cocoa backend
217
+
218
+ Everything about _drawing_ works — geometry, background, resize, close,
219
+ `transparent`, `theme`, `frameRate`, `hidden`. What a **window manager**
220
+ does differs, since macOS has none:
221
+
222
+ | prop | on Cocoa |
223
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
224
+ | `minWidth`/`minHeight`/`maxWidth`/`maxHeight` | honoured |
225
+ | `resizable`, `decorations` | honoured, but read **once, at creation** |
226
+ | `transparent`, `borderRadius` | always available: every window composites |
227
+ | `states: ['demands_attention']` | the Dock bounce, held until the state is removed |
228
+ | `fullscreen`, `states: ['maximized' \| 'hidden']` | the full-screen Space, the zoom, the Dock |
229
+ | `onStatesChange`, `useWindowState()` | fire for full screen (from the transition's start), zoom, Dock, key window; `desktop`/`obscured` never change |
230
+ | `embeddable` | a `<Frame>` pane, through shared surfaces ([frame.md](frame.md)) |
231
+
232
+ **Inert** — accepted and, today, silently ignored, so shared code stays
233
+ branch-free: every other `_NET_WM_STATE` (`sticky`, `below`,
234
+ `skip_taskbar`, `shaded`, …), `appId`, `windowType`, `gravity`, the
235
+ increment and aspect hints, and `transientFor` (a managed `<Dialog>` already
236
+ floats on its own window). `onClientMessage` never fires. Why:
237
+ [macos.md](macos.md#windowing-semantics-what-maps-what-bends-what-breaks).
238
+
239
+ ### Natural size
240
+
241
+ A window with no `width`/`height` is **sized from its content and capped at
242
+ the screen**. `'auto'` says so explicitly, per axis:
243
+
244
+ ```jsx
245
+ <window title="prefs" /> {/* natural, both ways */}
246
+ <window width={600} height="auto" /> {/* height follows the content */}
247
+ <window width="auto" maxWidth={720} minHeight={200} /> {/* auto within your own bounds */}
248
+ ```
249
+
250
+ This is CSS's shrink-to-fit — a top-level window has nothing to stretch
251
+ into; stretching into the screen is `fullscreen`. The size is worked out
252
+ before the window is created, so it never appears at the wrong size.
253
+
254
+ **How it is measured** — the second step is where the surprises are:
255
+
256
+ 1. The tree is laid out with no available width, so nothing wraps and the
257
+ root reports its **max-content** width.
258
+ 2. That is clamped into `[minWidth, min(maxWidth, the screen)]` — the size
259
+ hints bound the auto size as `min-width`/`max-width` bound `width: auto`;
260
+ `minWidth` wins a conflict.
261
+ 3. The tree is laid out **again at that width**, and the height comes from
262
+ there: a paragraph that had to wrap is taller than step 1 said.
263
+
264
+ Unlike in CSS, the screen cap beats min-content: what does not fit is cut.
265
+
266
+ **The cap** is the usable area of the monitor the window opens on — its
267
+ Xinerama rect less the panels in `_NET_WORKAREA` — read once while
268
+ `createRoot()` connects; the screen on a server with neither; none in the
269
+ headless mock. The window manager's frame is not counted, so a window at
270
+ the cap is a titlebar too tall and the WM trims it.
271
+
272
+ **Afterwards, auto follows the content**, growing and shrinking with it,
273
+ until the user resizes the window or the window manager imposes a size;
274
+ then the window is theirs. A numeric `width` does the same, and `'auto'`
275
+ again hands it back. The window's own resize requests coming back are not a
276
+ takeover.
277
+
278
+ On X11 a re-fit is laid out once, at the new size: the frame waits for the
279
+ server to apply it (up to 150 ms, if the window manager refuses), so grown
280
+ content is never drawn cut off; the old pixels stay up meanwhile.
281
+ `REACT_X11_NO_REFIT_HOLD=1` turns the wait off
282
+ ([debugging.md](debugging.md#react_x11_no_refit_hold1)). On Cocoa a window
283
+ is a whole number of **points**, rounded up (100.5 makes 101), and still
284
+ follows its content.
285
+
286
+ - A scroll container reports its **content** height, so an auto window
287
+ around a long list opens screen-tall. Give the window a `height`, or the
288
+ scroller a `maxHeight`.
289
+ - An auto width is the **unwrapped** row, so it depends on the UI font.
290
+ - A window tracking its content costs an extra layout pass on each frame
291
+ that lays out; one with both sizes given pays nothing.
292
+
293
+ ### A floor the content decides
294
+
295
+ `minWidth`/`minHeight` also take `'auto'`: **the smallest size the content
296
+ can be drawn at**, measured and handed to the window manager so the user
297
+ cannot drag the window below it. Opt-in — Qt and GTK do it by default —
298
+ since CSS lets content overflow.
299
+
300
+ ```jsx
301
+ <window minWidth="auto" minHeight="auto"> {/* never smaller than my content */}
302
+ <window width={900} minWidth="auto" /> {/* my opening size, the content's floor */}
303
+ ```
304
+
305
+ **How the floor is measured**: the tree is laid out with _no room at all_,
306
+ and the floor is how far it still reached. Each node contributes what its
307
+ own style lets it shrink to:
308
+
309
+ | the node | contributes |
310
+ | --------------------------------- | ---------------- |
311
+ | a box that named a size | that size |
312
+ | a wrapping row | its widest item |
313
+ | text | its longest word |
314
+ | `overflow: 'scroll'` / `'hidden'` | nothing |
315
+ | a `minWidth: 0` of its own | nothing |
316
+ | a `minWidth: 240` of its own | 240 |
317
+
318
+ A registered element contributes its `measureContent` answer for the
319
+ smallest size ([extending.md](extending.md#a-size-of-your-own)). This is
320
+ every node's own floor
321
+ ([above](#everything-shrinks-nothing-shrinks-to-nothing)) read at the root,
322
+ with the same escape hatch: a node that names a floor of its own stops its
323
+ contents counting, so a window around a scrolling pane is floored by
324
+ everything _except_ that pane.
325
+
326
+ `maxWidth`/`maxHeight="auto"` mean **the size the content wanted** — the
327
+ natural size, not the floor. Where nothing can shrink they are equal, so
328
+ `minWidth="auto" maxWidth="auto"` is a fixed-size dialog, as is
329
+ `resizable={false}`.
330
+
331
+ - **A floor is a request.** mutter, kwin and xfwm enforce it at the drag; a
332
+ tiling window manager may not, and the renderer will not fight it — clip
333
+ gracefully anyway.
334
+ - **`minHeight="auto"` is for the current width**, re-sent as that changes:
335
+ a paragraph is tallest at its narrowest.
336
+ - **The floor outlives the size.** An `'auto'` size stops tracking once the
337
+ user takes over; an `'auto'` bound does not. It costs a layout pass per
338
+ axis on frames that lay out, and is sent only when it moves.
339
+
340
+ ### `hidden` — realized, laid out, not on screen
341
+
342
+ ```jsx
343
+ <window hidden={minimized} title="scratch" />
344
+ ```
345
+
346
+ The window exists at its (measured) size with a live tree, but is not
347
+ mapped while `hidden`; clearing it maps the window where it stands. Nothing
348
+ unmounts, so state, subscriptions and the window survive — declarative
349
+ "minimize by unmapping", and how [`Tooltip`](components.md#tooltip)
350
+ measures a label before placing it.
351
+
352
+ `<Activity mode="hidden">` or a suspended `<Suspense>` compose with it: the
353
+ window shows only while **neither** hides it. Hiding releases the keyboard
354
+ focus inside ([focus-follows-visibility](events.md)); the reveal restores it
355
+ unless something else took it. On a managed `<window>` hiding is ICCCM's
356
+ _withdraw_, so on reveal the window manager may frame or place it anew, and
357
+ `states` are re-applied rather than remembered; a `<popup>` just unmaps and
358
+ maps.
359
+
360
+ ### `frameRate` — pacing the frames under a flood
361
+
362
+ ```jsx
363
+ <window frameRate="adaptive">…</window>
364
+ ```
365
+
366
+ By default a window paints every frame the clock gives it — on X11 as often
367
+ as the server keeps up, on macOS at the display's rate — right for a UI
368
+ answering input. For content that changes faster than anyone reads — a
369
+ terminal under `cat`, a socket-fed chart, a simulation — most of the JS
370
+ thread then goes to frames nobody sees, starving the producer.
371
+
372
+ `frameRate` prices frames in CPU time. Under `'adaptive'` painting may take
373
+ a quarter of the wall time while busy: credit accrues at that rate, each
374
+ frame spends what it cost (the flush, and on macOS the present), and a frame
375
+ claimed in debt waits until the credit is back to zero. So:
376
+
377
+ - **Idle is immediate**: the first frame after a pause paints on the next
378
+ tick, whatever the last cost.
379
+ - **Cheap is unthrottled**: a frame cheaper than its credit is never held,
380
+ so a blit-scrolled list keeps the display's rate.
381
+ - **A rare expensive frame is free**: the bucket holds a 50ms burst. A
382
+ _stream_ of them waits `cost × (1/budget − 1)` each — 18ms after a 6ms
383
+ frame.
384
+ - **A flood that ends is unthrottled at once**: the debt never exceeds one
385
+ frame's cost.
386
+
387
+ `minFps` bounds staleness: the screen is never more than `1000 / minFps` ms
388
+ behind the last paint. `maxFps` caps the rate, cheap frames included.
389
+
390
+ | value | `budget` | `minFps` | `maxFps` | when |
391
+ | ---------------------------- | -------: | -------: | -------: | --------------------------------------------------------- |
392
+ | `'display'` (the default) | 1 | none | none | a UI answering input: every frame, after React's batching |
393
+ | `'adaptive'` | 0.25 | 20 | none | fed off an input path: a terminal, a live chart |
394
+ | `'throughput'` | 0.1 | 10 | 30 | output over display: may lag up to 100ms during a flood |
395
+ | a number, `60` | 1 | none | 60 | a ceiling and nothing else |
396
+ | `{ budget, minFps, maxFps }` | | | | any subset; the rest are `'display'`'s |
397
+
398
+ A budget below 1 with no `minFps` warns in development: nothing would bound
399
+ the wait after an expensive frame.
400
+
401
+ Precedence: **`REACT_X11_FRAME_RATE`** (`adaptive`, `display`,
402
+ `throughput` or a number) overrides everything, so an A/B run needs no code
403
+ change; then **the prop**; then **`createRoot({ frameRate })`**, the
404
+ default for every window of that root; then `'display'`. A `<popup>` takes
405
+ its own prop the same way; a [`<glarea>`](#glarea) takes its window's unless
406
+ it names one.
407
+
408
+ The pacer never changes what is painted (held damage accumulates into the
409
+ next frame) and never delays the answer to input: a click or key paints at
410
+ once ([events.md](events.md)). It prices JS-thread time only, so on X11 it
411
+ rarely holds a frame, and it is inert where a backend has backpressure of
412
+ its own. `REACT_X11_TRACE=requests` prints `waited=` on each held frame
413
+ ([debugging.md](debugging.md)). Design notes:
414
+ [frame pacing](../design/architecture/frame-pacing.md).
415
+
416
+ ### `onResize` fires for moves
417
+
418
+ `onResize` reports _any_ geometry change: under a window manager that moves
419
+ windows opaquely, a title-bar drag fires it per pointer step at the same
420
+ size. The renderer spends nothing on a move; a handler should check which it
421
+ got:
422
+
423
+ ```jsx
424
+ <window
425
+ onResize={(ev) => {
426
+ if (ev.resized) refit(ev.width, ev.height);
427
+ if (ev.moved) rememberPosition(ev.x, ev.y);
428
+ }}
429
+ />
430
+ ```
431
+
432
+ `ev.previous` is the geometry these compare against. Once a reparenting
433
+ window manager has framed the window, `ev.x`/`ev.y` are frame-relative, not
434
+ screen coordinates.
435
+
436
+ ### Stacking
437
+
438
+ Nested `<window>`s stack like drawn siblings — later on top, `zIndex` over
439
+ document order — and reordering them in JSX restacks the real windows.
440
+ Top-level windows are the window manager's to stack, so their tree order
441
+ means nothing; use `alwaysOnTop`.
442
+
443
+ ```jsx
444
+ <window>
445
+ <window key="back" /> {/* bottom */}
446
+ <window key="front" /> {/* on top */}
447
+ <window key="always" zIndex={1} /> {/* above both, wherever it sits here */}
448
+ </window>
449
+ ```
450
+
451
+ ### Window manager hints
452
+
453
+ Read by the window manager, applied at mount and on update (unchanged
454
+ values are not re-sent):
455
+
456
+ - `resizable={false}` pins min and max size to the current size.
457
+ - Size hints: `minWidth`, `minHeight`, `maxWidth`, `maxHeight`, `widthInc`,
458
+ `heightInc`, `baseWidth`, `baseHeight`, `minAspect`, `maxAspect`,
459
+ `gravity`.
460
+ - `appId`: `'instance'`, `['instance', 'Class']` or `{instance, class}`.
461
+ - `windowType`: `'dialog'`, `'utility'`, `'tooltip'`… or an array of
462
+ fallbacks.
463
+ - `decorations={false}` asks for no titlebar or border.
464
+
465
+ ```jsx
466
+ <window width={400} height={300} resizable={false} windowType="dialog" />
467
+ <window width={400} height={300} minWidth={320} minHeight={200} />
468
+ <window width={400} height={300} minWidth="auto" /> {/* measured — see above */}
469
+ ```
470
+
471
+ On a `<window>` these names are the window's, not yoga's: `width`/`height`
472
+ are the real geometry the user can drag, `minWidth`/`maxHeight` what the WM
473
+ enforces, and the _contents_ are laid out by `style`. The four bounds also
474
+ take `'auto'` ([a floor the content decides](#a-floor-the-content-decides)),
475
+ and on an auto-sized window they bound the natural size, so
476
+ `<window resizable={false} windowType="dialog" />` is pinned at its measured
477
+ size — the fixed-size dialog.
478
+
479
+ ### `transientFor` — a window that belongs to another
480
+
481
+ Without it, a secondary window is an unrelated application to the window
482
+ manager: its own taskbar and alt-tab entry, placed anywhere, stacked
483
+ independently, not minimised with its owner.
484
+
485
+ ```jsx
486
+ const main = useRef(null);
487
+
488
+ <window ref={main} title="editor">…</window>
489
+ <window transientFor={main} windowType="dialog" title="Preferences">…</window>
490
+ ```
491
+
492
+ Takes a ref to a `<window>`/`<popup>` or to **any drawn node** (resolved to
493
+ its window, so an out-of-flow `<box>` in the owner is a good handle), a raw
494
+ XID, `'root'` (this client's whole window group), or `null` to clear. A ref
495
+ still null at mount — an owner mounted as a sibling in the same commit — is
496
+ retried next frame.
497
+
498
+ Set it **with** `windowType="dialog"`. Under EWMH a managed transient with
499
+ no type already is a dialog (kept out of taskbar and pager), but a `dialog`
500
+ with no owner has nothing to stack above or iconify with. Stacking above
501
+ the owner and iconifying with it are window-manager policy (quartz-wm does
502
+ neither). **Inert on override-redirect windows** — every `<popup>` by
503
+ default — which the WM never manages; ntk warns if asked.
504
+
505
+ `windowIdOf(refOrInstance)` is the same resolution: the XID, or null. An
506
+ xdg-desktop-portal `parent_window` is `` `x11:${id.toString(16)}` `` —
507
+ lowercase hex, **no** `0x`. Today's portal backends tolerate a prefix, so
508
+ the mistake goes unnoticed; Qt's parser returns 0 on failure, which would
509
+ mean a silently unparented, non-modal dialog. `useWindowId(ref)` is the
510
+ hook form, returning a getter like `useAnchor`.
511
+
512
+ `decorations={false}` writes `_MOTIF_WM_HINTS` (Mutter, KWin, Xfwm,
513
+ Openbox, i3); a window manager that ignores it decorates anyway.
514
+
515
+ ### Closing a window
516
+
517
+ Every window the window manager manages advertises `WM_DELETE_WINDOW`, with
518
+ or without `onCloseRequest` — otherwise the close button would kill the
519
+ connection mid-frame, skipping effect cleanup. Windows the WM does not
520
+ frame (a child `<window>`, an override-redirect `<popup>`) do not; a
521
+ [managed `<popup>`](#a-managed-popup-is-a-dialog) does.
522
+
523
+ - **No `onCloseRequest`**: the **primary window** unmounts the tree and
524
+ closes the connection — effects clean up and the process ends on a drained
525
+ loop. Any other window refuses, with a dev warning.
526
+ - **`onCloseRequest`**: only your handler runs — `setOpen(false)` for a
527
+ dialog, a "save your work?" prompt, `root.unmount()` to quit.
528
+
529
+ The primary window is the first top-level `<window>` that is not
530
+ `transientFor` another and has no `windowType`; a lone window is primary
531
+ whatever its type. Secondary windows refuse because closing a
532
+ `{open && <window/>}` behind React's back would leave a window the app
533
+ believes open and can never reopen. Give a dialog a handler:
534
+
535
+ ```jsx
536
+ {
537
+ showSettings && (
538
+ <window
539
+ title="Settings"
540
+ transientFor={main}
541
+ onCloseRequest={() => setShowSettings(false)}
542
+ />
543
+ );
544
+ }
545
+ ```
546
+
547
+ ### Window state
548
+
549
+ `states` is EWMH `_NET_WM_STATE`: `modal`, `sticky`, `maximized` (or
550
+ `maximized_vert`/`maximized_horz`), `shaded`, `skip_taskbar`, `skip_pager`,
551
+ `hidden`, `fullscreen`, `above`, `below`, `demands_attention`, `focused`.
552
+ `fullscreen` and `alwaysOnTop` are sugar for `fullscreen` and `above`,
553
+ unioned with `states`. On the [Cocoa backend](macos.md) `fullscreen` is the
554
+ full-screen Space, `maximized` the zoom, `hidden` the Dock, and
555
+ `demands_attention` bounces the Dock icon until the app is activated or the
556
+ state removed ([desktop.md](desktop.md)); the rest are inert.
557
+
558
+ ```jsx
559
+ <window
560
+ states={['skip_taskbar']}
561
+ fullscreen={isFullscreen}
562
+ onStatesChange={(states) => setFullscreen(states.includes('fullscreen'))}
563
+ />
564
+ ```
565
+
566
+ **Controlled, and diffed against the previous props** — not the window's
567
+ current state, which the window manager changes behind the app's back
568
+ (maximize, a hotkey leaving fullscreen, a tiling WM). A state is re-sent
569
+ only when the app changes it, so a WM that disagreed is not fought.
570
+ `onStatesChange` reports what the WM did; without it the property is not
571
+ watched. States are applied **before mapping**, so a window opens fullscreen
572
+ without flashing at normal size. On XQuartz, whose quartz-wm lacks
573
+ `_NET_WM_STATE_ABOVE`, `alwaysOnTop` uses Apple-WM window levels.
574
+
575
+ ### `onClientMessage` — speaking a protocol of your own
576
+
577
+ ClientMessage carries nearly every X11 convention — EWMH requests, XEmbed,
578
+ the system tray, protocols between copies of one app. `onClientMessage`
579
+ gets every one addressed to this window:
580
+
581
+ ```jsx
582
+ <window
583
+ onClientMessage={(ev) => {
584
+ if (ev.messageType !== '_NET_SYSTEM_TRAY_OPCODE') return;
585
+ if (ev.data[1] === SYSTEM_TRAY_REQUEST_DOCK) dock(ev.data[2]);
586
+ }}
587
+ />
588
+ ```
589
+
590
+ `ev.messageType` is the atom's **name** (no interning needed), `ev.atom` its
591
+ id, `ev.format` 8, 16 or 32, `ev.data` the payload at that width — 5, 10 or
592
+ 20 values. No setup; a window without the prop pays nothing.
593
+
594
+ - **Order is preserved**, as chunked protocols need
595
+ (`_NET_SYSTEM_TRAY_BEGIN_MESSAGE` then `_NET_SYSTEM_TRAY_MESSAGE_DATA`).
596
+ `messageType` is `null` only for an atom the **server** does not know — a
597
+ broken sender.
598
+ - **Scoped to the window the message names**, unlike
599
+ `useApp().X.on('event')`, which sees every window's events and outlives
600
+ the element. Messages to other windows, the EWMH ones sent to the root
601
+ included, do not arrive; a window manager wants the raw stream
602
+ (`examples/wm-core.js`).
603
+ - **`preventDefault()` stops react-x11's own handling** — today XDND, for a
604
+ window speaking the drag protocol itself. The close button is
605
+ `onCloseRequest`'s.
606
+
607
+ Send with `useApp().X.SendClientMessage(destination, aboutWindow, atom,
608
+ format, data, eventMask)`, or ntk's `window.sendClientMessage(name, data)`,
609
+ which interns the atom. EWMH messages _about_ a window go to the **root**;
610
+ to another application's window pass event mask `0`. Taking a selection
611
+ needs a timestamp, not `CurrentTime`:
612
+ [`lastInputTime` and `serverTime`](clipboard.md#owning-a-selection-of-your-own).
613
+
614
+ ### Kiosk
615
+
616
+ No `kiosk` prop — it is a combination:
617
+
618
+ ```jsx
619
+ <window
620
+ fullscreen
621
+ decorations={false}
622
+ states={['above', 'skip_taskbar', 'skip_pager']}
623
+ style={{ cursor: 'none', flexGrow: 1 }}
624
+ />
625
+ ```
626
+
627
+ `cursor: 'none'` hides the pointer; an unset `cursor` inherits the root
628
+ window's ([styling.md](styling.md)).
629
+
630
+ ### `transparent` — rounded corners and translucency
631
+
632
+ `transparent` creates the window on a 32-bit ARGB visual: what the tree
633
+ leaves unpainted stays empty, and a compositor blends it with what is
634
+ behind. With `borderRadius` in the window's own `style` that gives rounded
635
+ corners with an **antialiased** edge (alpha, not a 1-bit Shape mask):
636
+
637
+ ```jsx
638
+ <popup
639
+ transparent
640
+ grab
641
+ style={{ backgroundColor: 'rgba(24, 24, 30, 0.86)', borderRadius: 14 }}
642
+ >
643
+ …
644
+ </popup>
645
+ ```
646
+
647
+ `backgroundColor` may be translucent; unset, the window is empty but for
648
+ what the tree paints. A window's `borderRadius` works **only** with
649
+ `transparent`; on an opaque window it is ignored (the corners would show
650
+ the server's white).
651
+
652
+ ### When transparency is not available
653
+
654
+ **On the Cocoa backend it always is.** On X11 it needs a **depth-32
655
+ TrueColor visual** (XQuartz has none) and a **running compositor** (Mutter,
656
+ KWin, picom, …); without one, painted-away corners show **black**.
657
+ react-x11 prevents that: where transparency would not be seen, the window
658
+ is filled edge to edge, its `borderRadius` ignored and a translucent
659
+ `backgroundColor` flattened — square and opaque, with nothing asked of the
660
+ app. Design the other side with `'@supports transparency'`:
661
+
662
+ ```jsx
663
+ <popup
664
+ transparent
665
+ style={{
666
+ backgroundColor: '#1c1c22', // square and opaque, works everywhere
667
+ '@supports transparency': {
668
+ backgroundColor: 'rgba(24, 24, 30, 0.86)',
669
+ borderRadius: 14,
670
+ },
671
+ }}
672
+ />
673
+ ```
674
+
675
+ The block ([Capability queries](styling.md#capability-queries)) is answered
676
+ per window and re-resolved live: a compositor started mid-session rounds the
677
+ popup without a remount, which is why `transparent` takes the 32-bit visual
678
+ even when nothing composites yet. `useSupports('transparency')` asks the
679
+ same of the display, before any window exists:
680
+
681
+ ```jsx
682
+ const canBlend = useSupports('transparency');
683
+ const margin = canBlend ? 26 : 0; // room for a client-drawn shadow
684
+ ```
685
+
686
+ `REACT_X11_NO_TRANSPARENCY=1` shows the fallback on a compositing display:
687
+ `transparent` is ignored and both answer false
688
+ ([debugging.md](debugging.md)). Toggling `transparent` on a mounted window
689
+ does nothing until it remounts (change its `key`): the visual is fixed at
690
+ creation.
691
+
692
+ Input still hits the full rectangle — corners are invisible, not
693
+ click-through (the Shape input region is reachable through the ntk ref).
694
+ Child `<window>`s are composited by the X server, not the compositor, so a
695
+ transparent window nested in another blends against nothing.
696
+
697
+ ## `<popup>`
698
+
699
+ An override-redirect top-level window at **screen coordinates** — menus,
700
+ tooltips, dropdowns — that the window manager ignores (no decorations, no
701
+ focus stealing). Its place in the JSX tree does not affect its place on
702
+ screen; it is its own paint and event root, and conditional rendering
703
+ controls its lifetime. `anchor` places it against a node
704
+ ([below](#anchor--a-popup-that-places-itself)); `x`/`y` place it in screen
705
+ logical pixels, e.g. at `ev.screenX`/`screenY`. Same props as `<window>`,
706
+ **including [natural size](#natural-size)** — a menu sized by its rows.
707
+
708
+ | prop | |
709
+ | ------------------ | ------------------------------------------------------------------------------------------------ |
710
+ | `anchor` | place against a node, a rect inside one, or a screen rect, at the content's size |
711
+ | `grab` | hold a pointer grab while up — how menus behave on X (below) |
712
+ | `grabKeyboard` | take the keyboard while up — for a popover a tray click opened (below) |
713
+ | `transparent` | rounded corners and translucency ([above](#transparent--rounded-corners-and-translucency)) |
714
+ | `onDismiss` | a press landed outside the popup: close it |
715
+ | `trapFocus` | own a focus scope: a modal ([events.md](events.md#focus-scopes-modals)) |
716
+ | `overrideRedirect` | `false` makes it a WM-managed window — a real dialog (below) |
717
+ | `dragPreview` | a drag preview following the pointer, never a drop target ([drag-and-drop.md](drag-and-drop.md)) |
718
+
719
+ ### `anchor` — a popup that places itself
720
+
721
+ ```jsx
722
+ function Completions({ editorRef, caret, matches, onPick }) {
723
+ if (!matches.length) return null;
724
+ return (
725
+ <popup
726
+ anchor={{ to: editorRef, at: caret, placement: 'bottom' }}
727
+ maxHeight={220}
728
+ >
729
+ <box
730
+ style={{ overflow: 'scroll', padding: 4, backgroundColor: '$surface' }}
731
+ >
732
+ {matches.map((m) => (
733
+ <box key={m.label} onClick={() => onPick(m)} style={{ padding: 6 }}>
734
+ <text>{m.label}</text>
735
+ </box>
736
+ ))}
737
+ </box>
738
+ </popup>
739
+ );
740
+ }
741
+ ```
742
+
743
+ That is the whole completion list: **at the caret**, as wide as its widest
744
+ label, at most 220 tall and scrolling past that, flipped above the line near
745
+ the screen's bottom — no font measured, size stated or position named.
746
+
747
+ `anchor` takes [`anchorRect`'s options](components.md#useanchorref--anchorrectnode-options)
748
+ — `placement`, `align`, `offset`, `alignOffset`, `alignTo`, `at` — plus `to`,
749
+ the node (or a ref to one) it hangs off; `x`/`y` are ignored while it is
750
+ set. The popup is measured, placed (flip and screen-edge clamp depend on its
751
+ size) and only then created, so it appears at the right size in the right
752
+ place. It then follows everything that can move either half: the anchor's
753
+ layout, an ancestor scrolling, the owner window moving, its own content
754
+ growing.
755
+
756
+ `at` is a rect **inside** the node, in its coordinates —
757
+ `{ x: caretX, y: lineTop, width: 1, height: lineHeight }` — read by the
758
+ flip, the alignment and `offset` as a small widget there would be; `{x, y}`
759
+ alone is a point.
760
+
761
+ An anchor ref not attached yet (written _above_ its trigger) is waited for,
762
+ one frame, rather than opening at the screen corner. **When the anchor
763
+ scrolls out of view the popup unmaps**, and returns — re-taking its
764
+ `grab` — with the anchor; a real window cannot be clipped by the anchor's
765
+ ancestors. To _close_ it instead, place it yourself with
766
+ [`useAnchorTracking`](components.md#useanchorref--anchorrectnode-options)'s
767
+ `onOutOfView`.
768
+
769
+ A popup never takes the X input focus, but nodes in it can hold the **owner
770
+ window's** focus — with `trapFocus` and `autoFocus` that is a modal
771
+ ([Focus inside a `<popup>`](events.md#focus-inside-a-popup)). One with no
772
+ owner window takes keys with `grabKeyboard`
773
+ ([below](#a-popover-under-a-tray-item)).
774
+
775
+ **`grab` makes a menu dismissable.** Without it, a press elsewhere —
776
+ another app, the root window, even this app's own window frame (the window
777
+ manager's) — never reaches the client, and the menu stays open. With it the
778
+ press is redirected to the popup, lands outside it, and `onDismiss` fires.
779
+ The app's own windows still get their presses, so submenus and the owner
780
+ keep working; only a menu's root popup needs the grab. `Select`,
781
+ `ContextMenu` and `MenuBar` do this.
782
+
783
+ ### A popover under a tray item
784
+
785
+ A tray click has no node: [`useTray`](desktop.md#the-tray)'s `onClick`
786
+ reports the item's screen frame. `anchor` takes it as `rect` instead of
787
+ `to`, with the same flip, clamp and natural size, kept on that monitor and
788
+ below the menu bar:
789
+
790
+ ```jsx
791
+ function MenuBarApp() {
792
+ const [open, setOpen] = useState(null);
793
+ const close = () => setOpen(null);
794
+ useTray({ icon: 'waveform', onClick: setOpen });
795
+ if (!open) return null;
796
+ return (
797
+ <popup
798
+ anchor={{ rect: open, placement: 'bottom', align: 'center', offset: 6 }}
799
+ grab
800
+ grabKeyboard
801
+ onDismiss={close}
802
+ onBlur={close}
803
+ >
804
+ <Controls onClose={close} />
805
+ </popup>
806
+ );
807
+ }
808
+ ```
809
+
810
+ `rect` is logical screen pixels; `{ x, y }` alone is a point — all a
811
+ freedesktop tray reports. It moves only when replaced, and is never out of
812
+ view. Its monitor is the one the **whole rect** overlaps most (a menu-bar
813
+ item's frame starts above its display's top), else the nearest.
814
+
815
+ **`grabKeyboard`**: a menu-bar app has no window for keys to arrive
816
+ through, so the popup takes the keyboard while up:
817
+
818
+ | backend | what it is |
819
+ | ------- | ------------------------------------------------------------------------------- |
820
+ | X11 | a keyboard grab, taken and dropped with the map, as `grab` is for the pointer |
821
+ | macOS | a key-capable window at the popup level, activating the app — `NSPopover`'s way |
822
+ | Wayland | nothing more: a grabbing popup has the keyboard already |
823
+
824
+ On macOS the window's kind is fixed at creation, so the prop does not change
825
+ on a mounted popup. The popover closes on `onBlur` there (another app takes
826
+ the key window) and on `onDismiss` on X; pass both.
827
+
828
+ **Shortcuts work with nothing passed**: a binding belongs to the top-level
829
+ `<window>`, or in an app without one, to the root-level `<popup>` holding
830
+ the keyboard. So do `useFileDialog`'s parent, the global menu and
831
+ `useWindowState`.
832
+
833
+ ```jsx
834
+ function Controls({ onClose, onToggle }) {
835
+ useAccelerator([['space']], onToggle);
836
+ useAccelerator([['Escape']], onClose);
837
+ return <box style={{ padding: 12, gap: 8 }}>…</box>;
838
+ }
839
+ ```
840
+
841
+ A binding with neither a window nor a popup above it — above the
842
+ `if (!open)`, say — never fires; development says so once, and
843
+ [`scope`](events.md#accelerators) anchors one at a node you name.
844
+
845
+ ### Centred over something
846
+
847
+ `placement: 'center'` puts the popup **over** its anchor, middle on middle,
848
+ clamped to the monitor. Over a window's rect it is a dialog —
849
+ [`Dialog`](components.md#dialog) passes its owner's screen rect as `rect`:
850
+
851
+ ```jsx
852
+ <popup
853
+ anchor={{ rect: ownerRect, placement: 'center' }}
854
+ width={360}
855
+ height="auto"
856
+ >
857
+ ```
858
+
859
+ It is centred **once**: when its size changes later it keeps its top edge
860
+ and horizontal middle, moving only to stay on the monitor, so what the user
861
+ is reading does not jump. A managed one grows from wherever it was moved. A
862
+ new `rect`, or the `to` node moving, centres it again.
863
+
864
+ ### A managed `<popup>` is a dialog
865
+
866
+ `overrideRedirect` defaults to `true` — what makes a menu a menu. `false`
867
+ hands the popup to the window manager: framed, titled, movable, with a
868
+ close button (`onCloseRequest`). With `transientFor` it is a real dialog,
869
+ above its owner, out of the taskbar and alt-tab, minimising with the owner.
870
+
871
+ ```jsx
872
+ <popup overrideRedirect={false} transientFor={anchor} windowType="dialog"
873
+ title="Preferences" trapFocus>
874
+ ```
875
+
876
+ Turn `grab` off with it — a grab swallows the press that starts a WM drag —
877
+ so a press outside no longer dismisses it, as befits a dialog.
878
+ [`Dialog`](components.md#dialog) does this; `managed={false}` returns to
879
+ override-redirect.
880
+
881
+ `windowType` defaults to `"popup_menu"`; name yours (`"tooltip"`,
882
+ `"dropdown_menu"`, … — `Select`'s sheet and `Tooltip` do). It is
883
+ **additive**: the popup stays override-redirect, and compositors use the
884
+ type for consistent shadows and animations.
885
+
886
+ ## `<box>`
887
+
888
+ The flex container. All layout + paint + interaction props above.
889
+ **Ref**: the retained node — `abs` (`{x, y, width, height}` within the
890
+ window, valid after layout). With `overflow: 'scroll'` it is also the
891
+ **scroll container**.
892
+
893
+ ## Scrolling: `overflow: 'scroll'` {#scrolling}
894
+
895
+ There is no scrolling element: a `<box>` or `<window>` with
896
+ `overflow: 'scroll'` is a clipped viewport over its overflowing children, on
897
+ **both axes**:
898
+
899
+ ```jsx
900
+ <box style={{ overflow: 'scroll', flexGrow: 1 }}>{rows}</box>
901
+ ```
902
+
903
+ The wheel scrolls it; a draggable thumb is drawn on each overflowing axis.
904
+ `overflow: 'hidden'` clips without scrolling. Being a style, it toggles
905
+ without a remount (`overflow={dense ? 'scroll' : 'visible'}` keeps the node,
906
+ children and state); a box that stops scrolling resets its offset to 0, as
907
+ in CSS.
908
+
909
+ **It is a tab stop and takes keys** whenever it has somewhere to scroll, so
910
+ unfocusable content (a log, a long `<text>`) is readable without a pointer:
911
+
912
+ | key | |
913
+ | ------------------- | ------------------------------------ |
914
+ | arrows | one wheel notch (48px) on that axis |
915
+ | PageUp / PageDown | a viewport, keeping a sliver of it |
916
+ | Space / Shift+Space | the same, for the hand already there |
917
+ | Home / End | the top and the bottom |
918
+
919
+ One that fits its content is no tab stop, takes no keys and no wheel: **the
920
+ wheel chains past it** to the next scroll container out. The keys are a
921
+ default action — your `onKeyDown` runs first, and `preventDefault()` cancels
922
+ them. A pane with an `onClick` pages with Space and activates with Enter
923
+ ([events.md](events.md#space-and-enter-are-a-click)); a focusable row
924
+ _inside_ a pane takes both keys and pages with neither.
925
+
926
+ A press on the bar never reaches the row behind it. A thumb drag keeps its
927
+ grip; a track press pages towards it. Drags move by whole device pixels, as
928
+ the wheel does ([extending.md](extending.md#scrolling-content-you-painted)),
929
+ so nothing is drawn off the pixel grid. `<textarea>` behaves the same, and a
930
+ bar press there never moves the caret.
931
+
932
+ ### The layout defaults it brings
933
+
934
+ | default | when |
935
+ | ------------------------------ | ---------------------------------------- |
936
+ | `flexBasis: 0` | `flexGrow > 0` and no `width` / `height` |
937
+ | `minWidth: 0` / `minHeight: 0` | always |
938
+
939
+ `min-*: 0` lets a viewport be smaller than its content; the zero basis is
940
+ half of `flex: 1`. Without them a header/scroll/footer window grows past its
941
+ bounds and pushes the footer out of view. An explicit value always wins.
942
+
943
+ ### Props
944
+
945
+ | prop | |
946
+ | ------------------- | -------------------------------------------------------------------------------- |
947
+ | `onScroll(ev)` | `{scrollX, scrollY, contentWidth, contentHeight, viewportWidth, viewportHeight}` |
948
+ | `onViewport(ev)` | `{width, height, contentWidth, contentHeight}` whenever they change |
949
+ | `scrollbar={false}` | hide the drawn scrollbars |
950
+ | `scrollbarColor` | thumb color |
951
+
952
+ **Ref**: the node, plus `scrollTo` / `scrollBy` / `scrollIntoView(node)` /
953
+ `canScroll(dx, dy)` and `scrollX` / `scrollY` / `contentWidth` /
954
+ `contentHeight`. In TypeScript, `useRef<ScrollableNode>(null)` types them;
955
+ `DrawnNode` still works for a box you do not scroll.
956
+
957
+ `scrollTo(y)` scrolls vertically; `scrollTo({x, y})` either axis, leaving an
958
+ omitted one; `scrollBy` likewise. `canScroll(dx, dy)` says whether there is
959
+ room on the axis a delta names — what the wheel asks each node on its way
960
+ out, and what your own element implements
961
+ ([extending.md](extending.md#scrolling-content-you-painted)).
962
+
963
+ Horizontal content comes from children that will not shrink (a row of
964
+ fixed-width cells). Its extent is measured **through the subtree**, like
965
+ `scrollWidth`: a row stretched to the viewport whose cells overflow it
966
+ still scrolls; a node clipping its own children ends the measurement. A
967
+ horizontal wheel axis scrolls sideways, as does **Shift + vertical
968
+ wheel**. With both bars, each stops short of the other's corner. Distances
969
+ are the device's: with XI2 a touchpad scrolls by the fraction of a notch it
970
+ measured. A wheel notch and an arrow key are 48 pixels
971
+ ([events.md](events.md#wheel)).
972
+
973
+ `onViewport` fires from **layout**, so it arrives before anyone scrolls —
974
+ what a virtualized list (`Table`) needs to decide how many rows to build,
975
+ since an effect cannot read the size off the ref yet. `onLayout`
976
+ ([Interaction props](#interaction-props)) is the same signal on any element,
977
+ without the content size.
978
+
979
+ `scrollIntoView(node)` scrolls the minimum on both axes to show a
980
+ descendant; one larger than the viewport gets CSSOM's `block: nearest`
981
+ (left alone if it covers the viewport, else brought in by the nearer edge).
982
+ It is safe right after the node mounts — it resolves on the next layout
983
+ pass — and a node already in view leaves the pane alone.
984
+
985
+ `scrollTo` answers inside the call from the last pass's extent: the offset
986
+ moves and `onScroll` fires before it returns. If a pass is owed, whatever
987
+ that extent could not satisfy is re-applied against the new content before
988
+ the frame paints, so restoring a position from a mount-time
989
+ `useLayoutEffect` shows it in the first frame, and `scrollTo(Infinity)` from
990
+ a layout effect in the commit that added rows shows the end in the first
991
+ frame that has them. `Infinity` means the end wherever the pass finds it
992
+ (as the End key does) — prefer it to `contentHeight`, the last pass's
993
+ measurement, which falls short when a commit adds more than a viewport. A
994
+ `scrollBy` before the pass continues from where the pass puts the pane.
995
+ Until then `scrollX`/`scrollY` read the call's answer; `onScroll` reports
996
+ the pass's afterwards.
997
+
998
+ `onScroll` fires for **every** offset change. Wheel, keys, bars and calls on
999
+ a laid-out pane report inside the call. Layout's own moves — a held
1000
+ `scrollTo` landing, `scrollIntoView` resolving (focus on a row below the
1001
+ fold), the clamp when content shrinks or the viewport grows — report after
1002
+ the frame that shows them, only if the offset moved. The payload is read at
1003
+ delivery, so a handler mirroring the offset into state ends where the pane
1004
+ did.
1005
+
1006
+ ### On a `<window>`
1007
+
1008
+ `<window style={{ overflow: 'scroll' }}>` scrolls the window's own content
1009
+ with the same wheel, keys and bars; it stays a `frame` to a screen reader.
1010
+ Its ref is still ntk's `Window`, so reach the scroll API through a ref on a
1011
+ box inside it, or the node in `onViewport`.
1012
+
1013
+ ## `<text>`
1014
+
1015
+ Shaped, wrapped text (bidi, ligatures, kerning, font fallback). Strings and
1016
+ numbers are legal only inside `<text>`; nested `<text>`s are **style spans**
1017
+ of one paragraph, wrapped as one.
1018
+
1019
+ | prop | |
1020
+ | --------------------------------------------------- | ------------------------------------------------------ |
1021
+ | `color` | text color — inherited (see below) |
1022
+ | `fontSize`, `fontFamily`, `fontWeight`, `fontStyle` | ntk font style (fontconfig lookup + fallback) |
1023
+ | `fontVariationSettings` | a variable font's axes — see below |
1024
+ | `textRendering` | which glyph path to draw with — see below |
1025
+ | `letterSpacing` | space after every character, in px — see below |
1026
+ | `fontVariantNumeric`, `fontFeatureSettings` | figures and OpenType features — see below |
1027
+ | `textAlign` | `left`, `right`, `center`, `start`, `end` (bidi-aware) |
1028
+ | `lineHeight` | multiplier over the natural font line height |
1029
+ | `textWrap` | `wrap` (default) or `nowrap` |
1030
+ | `maxLines` | how many lines are kept — unlimited by default |
1031
+ | `textOverflow` | `clip` (default) or `ellipsis`, for what was cut |
1032
+
1033
+ The first four rows, `fontVariationSettings`, `textRendering`,
1034
+ `letterSpacing` and the two feature properties **inherit**, into spans and
1035
+ across `<box>`es alike
1036
+ ([styling.md](styling.md#inheritance-the-ink-the-face-and-the-size)); the
1037
+ rest belong to the `<text>` that owns the box.
1038
+
1039
+ `{ textWrap: 'nowrap', textOverflow: 'ellipsis' }` is one line ending in
1040
+ `…`; an ellipsis with no `maxLines` means one line. Only the pixels are cut
1041
+ — the accessible name, caret indices and `textContent()` keep the whole
1042
+ string
1043
+ ([styling.md](styling.md#keeping-text-on-one-line-and-saying-when-it-did-not-fit)).
1044
+ A font file of the app's own opens with `loadFont`/`useFont`, which return
1045
+ the family name for `fontFamily`
1046
+ ([styling.md](styling.md#a-font-file-of-your-own)).
1047
+
1048
+ `start`/`end` follow the box's **direction**, not the string's first strong
1049
+ character: the paragraph is laid out at that base level, so `"(12) files"`
1050
+ in an RTL panel punctuates as RTL, and text with no strong characters still
1051
+ lands on the right side
1052
+ ([styling.md](styling.md#direction-and-the-logical-edges)).
1053
+
1054
+ ### Variable fonts
1055
+
1056
+ `fontWeight` drives a variable font's `wght` axis, so one file serves every
1057
+ weight, those between named instances included; `fontVariationSettings`
1058
+ sets other axes by tag:
1059
+
1060
+ ```jsx
1061
+ <text style={{ fontFamily: 'Inter', fontWeight: 460 }}>
1062
+ Serious by default.
1063
+ </text>
1064
+ <text style={{ fontVariationSettings: { wdth: 87.5, slnt: -8 } }}>
1065
+ condensed
1066
+ </text>
1067
+ ```
1068
+
1069
+ It inherits, and spans may override it. Unknown axes are ignored and values
1070
+ clamped, so it is safe on any face; it compares **by value**, so a literal
1071
+ is fine. Faces are instantiated on demand but never quantized: a slider
1072
+ bound to an axis with `step={1}` asks for hundreds of faces as it drags —
1073
+ step the control. Use the `.ttf`; a variable `.woff2` cannot be
1074
+ instantiated (ntk's `docs/fonts.md`).
1075
+
1076
+ ### `textRendering`
1077
+
1078
+ CSS's property: which glyph path draws the text.
1079
+
1080
+ | value | |
1081
+ | -------------------- | --------------------------------------------------------- |
1082
+ | `auto` (default) | ntk decides from the size — right for UI text |
1083
+ | `geometricPrecision` | glyph origins exactly where shaping asked; nothing cached |
1084
+ | `optimizeSpeed` | ntk's cached glyph bitmaps, at any size |
1085
+ | `optimizeLegibility` | accepted, means `auto` — there is no hinting to turn on |
1086
+
1087
+ ```jsx
1088
+ <text style={{ fontSize: 96, textRendering: 'geometricPrecision' }}>
1089
+ Serious by default.
1090
+ </text>
1091
+ ```
1092
+
1093
+ The cached path puts each glyph on a whole pixel, so animating `wght` makes
1094
+ single glyphs jump while neighbours stand still; `geometricPrecision`
1095
+ places them where shaping asked, and the line slides. It inherits (a span
1096
+ may override it) and **never reflows** — only draw-time rounding differs —
1097
+ so it is safe to flip on a state change. It rasterizes outlines on every
1098
+ draw and caches nothing: right for animated display text, wrong for a
1099
+ static paragraph, hence `auto`.
1100
+
1101
+ ### Letter spacing and figures
1102
+
1103
+ `fontVariantNumeric: 'tabular-nums'` gives every digit one width, so a
1104
+ changing number (a readout beside a slider) stops shuffling its row; most
1105
+ UI faces (SF Pro, Inter) default to proportional figures.
1106
+
1107
+ ```jsx
1108
+ <text style={{ fontSize: 30, fontVariantNumeric: 'tabular-nums' }}>
1109
+ {hz} Hz
1110
+ </text>
1111
+ ```
1112
+
1113
+ Keywords are CSS's: `lining-nums`/`oldstyle-nums`,
1114
+ `proportional-nums`/`tabular-nums`, `diagonal-fractions`/`stacked-fractions`,
1115
+ `ordinal`, `slashed-zero` — several at once, one per pair
1116
+ (`'tabular-nums slashed-zero'`); `'normal'` restores the face's own.
1117
+
1118
+ `fontFeatureSettings` takes any OpenType feature by tag: an array to turn
1119
+ on, or an object of tag to on, off or alternate index. It wins where both
1120
+ name a feature; missing features are ignored; it compares by value.
1121
+
1122
+ ```jsx
1123
+ <text style={{ fontFeatureSettings: ['tnum', 'zero'] }}>0.10</text>
1124
+ <text style={{ fontFeatureSettings: { ss01: true, salt: 2 } }}>Rag</text>
1125
+ ```
1126
+
1127
+ `letterSpacing` is CSS's, in logical pixels, after every character (the
1128
+ last on a line too); negative tightens. Spaced text drops the optional
1129
+ ligatures (`liga`, `clig`, `dlig`, `hlig`) — name one in
1130
+ `fontFeatureSettings` to keep it.
1131
+
1132
+ ```jsx
1133
+ <text style={{ fontSize: 11, fontWeight: 600, letterSpacing: 1.26 }}>
1134
+ NOISE TYPE
1135
+ </text>
1136
+ ```
1137
+
1138
+ All three inherit, the two feature properties independently as in CSS (a
1139
+ `fontVariantNumeric` below replaces the one above, leaving an inherited
1140
+ `fontFeatureSettings` alone). All three re-measure on change, and none may
1141
+ go in a state block.
1142
+
1143
+ ### Tiny text
1144
+
1145
+ Text under **6 logical pixels** is painted as one strip of its ink per line
1146
+ instead of glyphs — in a minimap, a zoomed-out graph or a grid of thousands
1147
+ of cells, it is an illegible smudge anyway, and the strip costs one fill.
1148
+ Layout is unchanged, and the size is logical, so it holds on a 2x panel.
1149
+ `createRoot({ textStripBelow })` moves the threshold (`0` keeps glyphs at
1150
+ every size); `REACT_X11_TEXT_STRIP_BELOW` sets it without `createRoot`.
1151
+
1152
+ ## Selecting text
1153
+
1154
+ Labels are not selectable, as `Gtk.Label` is not: most desktop text is
1155
+ chrome, and a stray drag lighting up a caption is noise. An element opts in:
1156
+
1157
+ ```jsx
1158
+ <box selectable style={{ padding: 12 }}>
1159
+ <text style={{ fontSize: 20, fontWeight: 'bold' }}>Release notes</text>
1160
+ <text>Everything below this heading can be dragged over and copied.</text>
1161
+ </box>
1162
+ ```
1163
+
1164
+ It becomes a **surface**: a drag selects across all text under it, a double
1165
+ click takes a word, a triple click a block, Ctrl+A the surface, Ctrl+C
1166
+ copies. Releasing the button hands the text to **PRIMARY**, so a middle
1167
+ click in a terminal pastes it ([clipboard.md](clipboard.md)); macOS has no
1168
+ PRIMARY, so only a copy reaches the pasteboard
1169
+ ([clipboard.md](clipboard.md#primary-is-x11-only)).
1170
+
1171
+ ### What is in a surface
1172
+
1173
+ Everything under it that answers for its own text, in document order:
1174
+ `<text>`, and any element implementing the accessors in
1175
+ [extending.md](extending.md#answering-for-your-own-text) — a terminal, log
1176
+ view or code editor from outside this package joins the same drag, with no
1177
+ registration. **Not** in it:
1178
+
1179
+ - anything under `selectable={false}` (CSS's `user-select: none`) — a
1180
+ list's bullets, a table's chrome, a caption inside a document;
1181
+ - `<textinput>`, `<textarea>` and any element declaring `hasOwnSelection` —
1182
+ they keep their own selection, and a press in one is theirs;
1183
+ - `<svg>`, and registered elements that draw text without exposing
1184
+ character geometry.
1185
+
1186
+ ### What a copy assembles
1187
+
1188
+ Separators come from **layout**, not markup: text sharing a band of pixels
1189
+ is joined with a **tab**, text starting below with a **newline** — cells and
1190
+ rows for a table, a paragraph per line for prose. An item excluded with
1191
+ `selectable={false}` is absent from the copy as from the highlight.
1192
+
1193
+ ```jsx
1194
+ // copies as "name\tsize\nnotes\t4 kB"
1195
+ <box selectable>
1196
+ <box style={{ flexDirection: 'row' }}>
1197
+ <text style={{ width: 120 }}>name</text>
1198
+ <text>size</text>
1199
+ </box>
1200
+ <box style={{ flexDirection: 'row' }}>
1201
+ <text style={{ width: 120 }}>notes</text>
1202
+ <text>4 kB</text>
1203
+ </box>
1204
+ </box>
1205
+ ```
1206
+
1207
+ ### The props
1208
+
1209
+ | prop | |
1210
+ | ----------------------- | ----------------------------------------------------------------------------------- |
1211
+ | `selectable` | `true` starts a surface; `false` opts a subtree out of the one above it |
1212
+ | `selectionColor` | highlight behind selected text (default: theme's `selection`, a tint of its accent) |
1213
+ | `onSelectionChange(ev)` | `ev.text` is what a copy would take; `ev.isCollapsed` whether it is empty |
1214
+
1215
+ On the surface's node, through a ref: `selectAll()`, `clearSelection()`,
1216
+ `selectedText()`, `setSelection(anchor, focus)` — each end
1217
+ `{ node, index }`, indices in code points — and `textSelection`, a snapshot
1218
+ of `{ isCollapsed, text, ranges }`.
1219
+
1220
+ ### One selection, and where the keys go
1221
+
1222
+ **One selection is visible per application**: selecting in a document
1223
+ collapses a field's highlight and vice versa — which keeps PRIMARY honest.
1224
+
1225
+ A surface is a **focus target** (Ctrl+A and Ctrl+C must arrive somewhere),
1226
+ so it is in the Tab cycle; `tabIndex={-1}` removes it but keeps it
1227
+ press-focusable — right for a document in a larger UI, wrong for a reader
1228
+ whose window is the document. The pointer is an I-beam over it unless
1229
+ `cursor` says otherwise. The selection reaches assistive technology through
1230
+ `<text>`'s AT-SPI text interface
1231
+ ([accessibility.md](accessibility.md#text-controls)).
1232
+
1233
+ ### The geometry underneath
1234
+
1235
+ Four accessors on every drawn node build the selection, and are public — a
1236
+ caret rect is what an autocomplete popup anchors to
1237
+ (`<popup anchor={{ to, at }}>`), a hit test turns a click into an offset:
1238
+
1239
+ ```js
1240
+ const node = paragraphRef.current;
1241
+ node.textContent(); // 'Everything below this heading…'
1242
+ node.textIndexAt(ev.x, ev.y); // 14 — window coordinates in, a code point out
1243
+ node.textCaretRect(14); // { x, y, width: 0, height }
1244
+ node.textRangeRects(4, 14); // the bands a highlight over [4, 14) fills
1245
+ ```
1246
+
1247
+ Indices are **code points**, not UTF-16 units. Rects are in the owning
1248
+ window's device pixels, like `abs` and `contentBox()`; mouse `x`/`y` are
1249
+ logical ([scale.md](scale.md)). `textRangeRects` returns a band per line
1250
+ _and_ per direction run, since a range crossing from Latin into Arabic
1251
+ covers separate stretches of pixels.
1252
+
1253
+ ---
1254
+
1255
+ ## `<textinput>`
1256
+
1257
+ ![textinput](img/textinput.png)
1258
+
1259
+ Single-line editable text; caret and selection positions are exact across
1260
+ kerning, shaping and trailing whitespace.
1261
+
1262
+ | prop | |
1263
+ | --------------------------------- | ---------------------------------------------------------------------------------------------- |
1264
+ | `value` + `onChange(ev)` | controlled mode (display follows the prop) |
1265
+ | `defaultValue` | uncontrolled mode |
1266
+ | `onSubmit(ev)` | Enter/Shift+Enter; with Ctrl, Alt or Super, a shortcut's ([events.md](events.md#accelerators)) |
1267
+ | `name` | field name, echoed on the event |
1268
+ | `placeholder`, `placeholderColor` | shown when empty |
1269
+ | `maxLength` | code-point limit |
1270
+ | `selectionColor`, `caretColor` | selection/caret paint |
1271
+ | `inputMode` | IME hint: `numeric`, `decimal`, `tel`, `email`, `url` ([events.md](events.md#input-methods)) |
1272
+ | text style props | as `<text>` |
1273
+
1274
+ The field follows its box's **direction** as `<text>` does: value,
1275
+ placeholder and caret start at the base direction's start edge (the right
1276
+ under `direction: 'rtl'`), the value is shaped at that base level, and
1277
+ clicks, caret, selection and horizontal scroll mirror together
1278
+ ([styling.md](styling.md#inside-an-editable-field)).
1279
+
1280
+ ### The change event
1281
+
1282
+ `onChange` and `onSubmit` get a synthetic event, not a bare string:
1283
+
1284
+ ```jsx
1285
+ <textinput
1286
+ name="email"
1287
+ value={email}
1288
+ onChange={(ev) => setEmail(ev.target.value)}
1289
+ />
1290
+ ```
1291
+
1292
+ | on the event | |
1293
+ | ----------------------------------- | --------------------------------------------------- |
1294
+ | `ev.value`, `ev.target.value` | the text **after** the edit |
1295
+ | `ev.name`, `ev.target.name` | the `name` prop |
1296
+ | `ev.type` | `'change'` or `'submit'` |
1297
+ | `ev.target`, `ev.currentTarget` | the node (they are the same — this does not bubble) |
1298
+ | `ev.nativeEvent` | the X key event, or **null** — see below |
1299
+ | `preventDefault`, `stopPropagation` | present for uniformity; nothing reads them |
1300
+
1301
+ - **`ev.target.value` is the new value even when controlled**, before the
1302
+ parent re-renders — as in the DOM, which is why react-hook-form and formik
1303
+ work ([ecosystem/forms.md](ecosystem/forms.md)). `ev.target` is the live
1304
+ node, so read later it shows the current value; `ev.value` is a snapshot.
1305
+ - **`ev.nativeEvent` can be null** — a paste resolving, an undo or a
1306
+ pushed-back value has no X event. Guard it (downshift reads
1307
+ `nativeEvent.preventDownshiftDefault` unguarded).
1308
+
1309
+ `node.value = ''` sets the text without firing `onChange`, as on a DOM
1310
+ input — how react-hook-form's `register()` resets a field.
1311
+
1312
+ Keys: click/drag selection, double-click word, triple-click all, **dead
1313
+ keys and Compose** ([events.md](events.md#composition)), Backspace/Delete,
1314
+ arrows (+Shift extends), Ctrl+arrows and Ctrl+Backspace/Delete by word,
1315
+ Home/End, Ctrl+A, Ctrl+C/X/V on CLIPBOARD, middle-click paste from PRIMARY,
1316
+ selections (select-all included) owning PRIMARY
1317
+ ([X11 only](clipboard.md#primary-is-x11-only)),
1318
+ **Ctrl+Z / Ctrl+Shift+Z** (and Ctrl+Y) to undo and redo, and a
1319
+ **right-click menu**. On Cocoa the keys are the Mac's: ⌘A, ⌘C/X/V,
1320
+ ⌘Z/⇧⌘Z; ⌥ moves and deletes by word; ⌘←/→ and ⌘↑/↓ go to the ends of the
1321
+ line and the value; ⌘⌫ deletes to line start; ⌃A, ⌃E, ⌃K, ⌃Y and AppKit's
1322
+ other Emacs keys work while other ⌃ chords type nothing; ⌥ types what the
1323
+ layout puts on a key (`ß` for ⌥S) ([events.md](events.md#primary-modifier)).
1324
+ Focusable by default; shows the text cursor. `ev.preventDefault()` in
1325
+ `onKeyDown`/`onMouseDown` suppresses the built-in editing. Copying and
1326
+ pasting from elsewhere: [clipboard.md](clipboard.md).
1327
+
1328
+ ### The right-click menu
1329
+
1330
+ ![the built-in edit menu](img/textinput-menu.png)
1331
+
1332
+ Undo / Redo / Cut / Copy / Paste / Select All, with no wiring — each row
1333
+ live only when it would act, each running its shortcut's code.
1334
+ Right-clicking **inside** a selection keeps it; outside, the caret moves
1335
+ there first. Arrows walk enabled rows, Enter chooses, Escape or a press
1336
+ outside closes; the selection stays highlighted meanwhile.
1337
+
1338
+ **Paste** is greyed when nothing owns CLIPBOARD — from a session's second
1339
+ menu on, since the field starts watching when its menu first opens; without
1340
+ XFixes it stays enabled. On macOS it tracks the pasteboard's change count, a
1341
+ quarter second behind another app's copy
1342
+ ([clipboard.md](clipboard.md#knowing-when-it-changes)).
1343
+
1344
+ `contextMenu={false}` plus a `ContextMenu` of your own replaces it
1345
+ (`onContextMenu` still fires); `ev.preventDefault()` in `onContextMenu`
1346
+ suppresses it once. `openEditMenu(node, at, actions)` opens the same menu
1347
+ for anything else that edits or selects text
1348
+ ([extending.md](extending.md#the-standard-edit-menu)).
1349
+
1350
+ ### `sensitive`
1351
+
1352
+ `<textinput sensitive>`'s text **never reaches a selection**: Ctrl+C and the
1353
+ copy half of Ctrl+X do nothing, the menu has no Cut or Copy (absent, not
1354
+ greyed — greyed over a password looks like a bug), and selecting does not
1355
+ take PRIMARY. The caret, selection, arrows, undo and pasting _in_ are
1356
+ untouched. The clipboard, unlike the screen, is readable by every client on
1357
+ the display, and clipboard managers record it. `PasswordInput` sets it while
1358
+ the secret is revealed ([components.md](components.md#passwordinput)). On
1359
+ Wayland the field is also declared `sensitive_data` to the input method,
1360
+ with no surrounding text offered ([events.md](events.md#input-methods)).
1361
+
1362
+ **Ref**: the node, plus `value`, `undo()` / `redo()` and `canUndo` /
1363
+ `canRedo` — enough for a toolbar button beside the field.
1364
+
1365
+ ### Undo
1366
+
1367
+ Consecutive typing is one undo step, so Ctrl+Z takes back a word, not a
1368
+ keystroke; runs of Backspace coalesce too. A run ends at whitespace, at
1369
+ anything moving the caret (arrows, Home/End, a click, focus leaving), and
1370
+ around edits that are their own step: a paste, a cut, a replaced selection,
1371
+ Ctrl+Backspace (⌥⌫, ⌘⌫ and ⌃K on a Mac), a `<textarea>` newline. Undo
1372
+ restores the selection and puts the caret where the undone edit started. A
1373
+ composed character is one entry: Ctrl+Z after `dead_acute` `e` steps over
1374
+ `é`.
1375
+
1376
+ The stack holds up to 200 snapshots. A **controlled** `value` reports the
1377
+ restored text through `onChange` and waits for the prop, as typing does, so
1378
+ a parent filtering values has the same say over an undo. A value changed
1379
+ from outside (a form reset) is its own entry.
1380
+
1381
+ ## `<textarea>`
1382
+
1383
+ Multi-line editable text on `<textinput>`'s editing core. It word-wraps at
1384
+ the content width; Enter and Shift+Enter insert a newline; Ctrl+Enter (⌘↩
1385
+ on Cocoa) fires `onSubmit`, and with no `onSubmit` is left to a shortcut, as
1386
+ Alt+Enter and Super+Enter are except on Cocoa
1387
+ ([events.md](events.md#accelerators)). Up/Down move between visual lines
1388
+ keeping a goal column, Home/End go to the ends of the visual line, selection
1389
+ spans lines, and the view scrolls to follow the caret (and with the wheel).
1390
+ On Cocoa ⌘←/→ are Home/End, ⌘↑/↓ go to the ends of the value and ⌥↑/↓ to
1391
+ the ends of a paragraph ([events.md](events.md#primary-modifier)). A field
1392
+ whose text fits passes the wheel out to the pane or window behind it, as a
1393
+ `<box overflow="scroll">` does.
1394
+
1395
+ | prop | |
1396
+ | --------------- | -------------------------------------------------------------- |
1397
+ | `rows` | preferred height in text lines (default 3) |
1398
+ | everything else | as `<textinput>` (`onSubmit` fires on Ctrl+Enter, ⌘↩ on Cocoa) |
1399
+
1400
+ ## `<image>`
1401
+
1402
+ | prop | |
1403
+ | ---------- | -------------------------------------------------------------------------------------- |
1404
+ | `src` | client-side pixels, in any of the four forms below, or a symbol by name |
1405
+ | `picture` | `{ id, width, height }` — an existing server-side Picture, composited as-is |
1406
+ | `drawable` | `{ id, width, height, depth? }` — an existing Pixmap/Window, composited as-is |
1407
+ | `cacheKey` | the source's identity, when `src` is re-derived per render |
1408
+ | `alt` | the accessible name — what a screen reader says ([accessibility.md](accessibility.md)) |
1409
+
1410
+ `src`, `picture` and `drawable` are mutually exclusive; passing two throws.
1411
+ Size by `style`: with one dimension set the other follows the aspect ratio;
1412
+ with neither, the natural size (a server-side source's descriptor size),
1413
+ shrunk to the width on offer. `objectFit` places the picture, centred:
1414
+ `'fill'` (default) stretches, `'contain'` fits inside, `'cover'` fills and
1415
+ crops, `'none'` keeps its size, `'scale-down'` is the smaller of `'none'`
1416
+ and `'contain'`.
1417
+
1418
+ ```jsx
1419
+ <image src={photo} style={{ height: 64 }} /> // width follows the aspect ratio
1420
+ <image src={photo} style={{ width: 96, height: 96, objectFit: 'cover' }} />
1421
+ ```
1422
+
1423
+ ### `src` — pixels the client has
1424
+
1425
+ ```jsx
1426
+ <image src="./logo.png" /> // file path or file URL
1427
+ <image src={bytes} /> // encoded PNG, JPEG, WebP, …
1428
+ <image src={{ width, height, data }} /> // raw straight RGBA
1429
+ <image src={ntkImage} /> // an ntk Image (or Surface)
1430
+ ```
1431
+
1432
+ A path (or `new URL('./logo.png', import.meta.url)`) decodes asynchronously:
1433
+ the element is 0×0 until it lands, then reflows — style a `width` and
1434
+ `height` to avoid the jump. Raw RGBA and an ntk `Image` are synchronous;
1435
+ encoded bytes are synchronous where the decoder is (PNG and JPEG under Node)
1436
+ and land a moment later otherwise (any format under Bun, WebP elsewhere).
1437
+ Raw `data` is `width × height × 4` bytes of straight (non-premultiplied)
1438
+ RGBA, as `getImageData` returns. Sources are treated as immutable: pass a
1439
+ new object when the pixels change — encoded bytes too, which under Bun are
1440
+ read on another thread after the commit.
1441
+
1442
+ #### Formats, and what decodes them
1443
+
1444
+ The format comes from the bytes, never a file name; the decoder from the
1445
+ runtime:
1446
+
1447
+ | format | under Bun | under Node |
1448
+ | ---------------- | -------------------------------------------------- | ------------------------------------------ |
1449
+ | PNG, JPEG | `Bun.Image`, off the JavaScript thread | pngjs, jpeg-js — synchronous |
1450
+ | WebP | `Bun.Image`, off the JavaScript thread | image-in-browser, loaded on the first WebP |
1451
+ | GIF, BMP | `Bun.Image`, off the JavaScript thread | not decoded — the error says to convert |
1452
+ | TIFF, HEIC, AVIF | `Bun.Image` through ImageIO (macOS), WIC (Windows) | not decoded; nor under Bun on Linux |
1453
+
1454
+ Under Bun every format decodes on a worker thread (a 1080p JPEG costs the
1455
+ JavaScript thread ~110ms in jpeg-js), so bytes land a frame or so late, like
1456
+ a file. Where that matters — a test measuring the first frame — decode
1457
+ yourself; the result is used as-is everywhere:
1458
+
1459
+ ```jsx
1460
+ import { decodeImage } from 'ntk/image'; // PNG and JPEG, synchronous
1461
+
1462
+ const logo = <image src={decodeImage(bytes)} />;
1463
+ ```
1464
+
1465
+ `decodeImageBytes` from `react-x11/ntk` is the same ladder for an element
1466
+ that draws its own images (a document's `<img>`, a CSS background): an
1467
+ `Image`, or a promise of one where decoding is async; it throws or rejects,
1468
+ naming what to convert to, where `<image>` would show nothing.
1469
+
1470
+ Between the runtimes:
1471
+
1472
+ - **Animations show their first frame** — animated WebP anywhere, GIF under
1473
+ Bun (Bun 1.4's libwebp cannot decode animated WebP; those go to the
1474
+ JavaScript decoder).
1475
+ - **JPEG EXIF orientation is applied under Bun, not Node**: rotate portrait
1476
+ phone photos first, or use Bun.
1477
+ - **No decoder, no picture**: GIF under Node or HEIC under Bun on Linux
1478
+ logs what to convert to and shows nothing, like a missing file.
1479
+
1480
+ Pixels are uploaded once and composited every repaint. An ntk `Image` is
1481
+ used as-is and never destroyed by the element, so one `Image` in many
1482
+ `<image>`s is one decode and one upload. On Cocoa the element frees its
1483
+ upload of an `Image` it decoded; the upload of yours is freed when your
1484
+ `Image` is garbage-collected (ntk's `destroy()` frees only X copies).
1485
+
1486
+ ### `src={{ symbol }}` — the platform's own icons
1487
+
1488
+ ```jsx
1489
+ <box style={{ flexDirection: 'row', gap: 6, color: theme.textMuted }}>
1490
+ <image src={{ symbol: 'speaker.wave.3.fill', variableValue: volume }} />
1491
+ <text>Volume</text>
1492
+ </box>
1493
+ ```
1494
+
1495
+ A name instead of pixels: the platform's icon in the **text colour** (what
1496
+ `currentColor` is to `<svg>`). The names are those `useTray`'s `icon` and a
1497
+ menu item's `iconName` take:
1498
+
1499
+ | backend | names |
1500
+ | ------------- | ---------------------------------------------------------------------------------------------------- |
1501
+ | macOS | SF Symbols, like `speaker.wave.3.fill`, through `@windowkit/appkit` |
1502
+ | X11 / Wayland | freedesktop icon theme (the settings daemon's, else Adwaita, then hicolor), like `audio-volume-high` |
1503
+
1504
+ **Sized like the surrounding text**: an SF Symbol takes the inherited
1505
+ `fontSize` and `fontWeight` as point size and weight; an icon theme is asked
1506
+ for the matching size (16 beside 14px text), square. A styled
1507
+ `width`/`height` fits it into that box, centred. Source keys:
1508
+
1509
+ | key | |
1510
+ | --------------- | ------------------------------------------------------------------------------ |
1511
+ | `symbol` | the name |
1512
+ | `weight` | what `fontWeight` takes; defaults to the text's. SF Symbols only |
1513
+ | `scale` | `'small'`, `'medium'` (default) or `'large'`, beside the text. SF Symbols only |
1514
+ | `variableValue` | 0 to 1, how much of a variable symbol shows. SF Symbols, macOS 13 and later |
1515
+
1516
+ On Linux a `-symbolic` icon is preferred and takes the text colour; a
1517
+ full-colour one shows as it is. Lookup follows the Icon Theme
1518
+ Specification, read at the first symbol. **An unknown name takes no room
1519
+ and draws nothing** (one dev warning). The two icon sets do not share
1520
+ names, so pick by platform.
1521
+
1522
+ ### `cacheKey` — when the buffer is new but the picture is not
1523
+
1524
+ A component re-deriving its bytes per render (decoding a stream, slicing a
1525
+ capture) passes a new `src` each time — a fresh decode and upload. A
1526
+ `cacheKey`, the `<canvas cacheKey>` contract for sources, vouches that the
1527
+ buffer is the same picture: nothing is redone, and `<image>`s sharing a key
1528
+ share one decoded copy, freed with the last.
1529
+
1530
+ ```jsx
1531
+ <image src={frame.rgba} cacheKey={`frame:${frame.serial}`} />
1532
+ ```
1533
+
1534
+ The key must name the content, or stale pixels show (development warns
1535
+ when two sizes collide under one key). Ignored for an ntk `Image`; refused
1536
+ with `picture`/`drawable`.
1537
+
1538
+ ### `picture` / `drawable` — pixels the server already has
1539
+
1540
+ Content already on the X server — an offscreen pixmap, `NameWindowPixmap`
1541
+ from Composite, a cached tile — composites straight from that resource,
1542
+ with no readback, upload or round trip:
1543
+
1544
+ ```jsx
1545
+ <image picture={{ id: pic, width: 64, height: 64 }} />
1546
+ <image drawable={{ id: pixmap, width: 128, height: 96, depth: 32 }} />
1547
+ ```
1548
+
1549
+ You state the size (asking would be a round trip); descriptors compare by
1550
+ value. Scaling is server-side and bilinear; drawing a `picture` scaled sets
1551
+ its transform and filter for the composite and resets them to
1552
+ identity/nearest after, so a picture with a transform of its own should be
1553
+ drawn 1:1. `drawable` wraps the pixmap or window in a Picture the element
1554
+ owns (freed on unmount; the drawable stays yours); `depth` is 24 (default,
1555
+ the screen's and a Composite window pixmap's), 32 for argb, or 8, which
1556
+ composites as ink through its alpha (a mask preview); a `picture`'s format
1557
+ was fixed when it was created. Both accept richer objects with those
1558
+ fields — an ntk `Pixmap` goes straight in as `drawable`. Both are
1559
+ **X11-only**: on Cocoa they draw nothing (one dev warning); pass pixels to
1560
+ `src`.
1561
+
1562
+ ## `<video>` {#video}
1563
+
1564
+ ```jsx
1565
+ // frames the application decodes — every backend
1566
+ const frames = useVideoFrames({ width: 1280, height: 720, format: 'NV12' });
1567
+ useEffect(() => startDecoder(file, frames), [frames, file]);
1568
+ <video frames={frames} style={{ width: '100%', backgroundColor: '#000' }} />;
1569
+ ```
1570
+
1571
+ | prop | |
1572
+ | ----------------------------------------------------- | --------------------------------------------------------------------------------------------- |
1573
+ | `frames` | a `VideoFrames` sink you push decoded frames into — every backend |
1574
+ | `src` | a path or URL the platform plays, where `useSupports('mediaPlayback')` (below) |
1575
+ | `poster` | an `<image src>` value, shown while there is no frame |
1576
+ | `autoPlay`, `loop`, `muted`, `volume`, `playbackRate` | HTML's names and defaults — nothing plays until asked; for `src` |
1577
+ | `paused` | controlled when present; for `src` |
1578
+ | `onLoadedMetadata` | `{ width, height, duration }` once the size is known (a sink: on mount, `duration: Infinity`) |
1579
+ | `onPlay`, `onPause`, `onEnded`, `onTimeUpdate` | for `src` |
1580
+ | `onError` | the source failed, or `src` has no player; unhandled, one console warning |
1581
+
1582
+ `frames` and `src` are exclusive; passing both throws. It is a drawn node like
1583
+ `<image>`, so a video in a form sits under its popups and inside its scroll
1584
+ panes, which a child window or `<glarea>` cannot. **Size** is HTML's: the
1585
+ stream's size in logical pixels (1280x720 is 1280 wide at any scale), else
1586
+ the poster's, else 300x150; one styled axis derives the other from the
1587
+ ratio. `objectFit` defaults to `'contain'`; `backgroundColor` fills the
1588
+ bars.
1589
+
1590
+ ### `VideoFrames`, the sink
1591
+
1592
+ react-x11 decodes nothing; `useVideoFrames` / `createVideoFrames` make a
1593
+ sink, and every source ends in `push`:
1594
+
1595
+ ```js
1596
+ import { spawn } from 'node:child_process';
1597
+
1598
+ // ffmpeg decoding a file into NV12 frames on a pipe, paced to the clip's rate
1599
+ function startDecoder(file, frames) {
1600
+ const ff = spawn('ffmpeg', [
1601
+ '-re',
1602
+ '-i',
1603
+ file,
1604
+ '-f',
1605
+ 'rawvideo',
1606
+ '-pix_fmt',
1607
+ frames.format.toLowerCase(),
1608
+ '-s',
1609
+ `${frames.width}x${frames.height}`,
1610
+ '-',
1611
+ ]);
1612
+ const size = frames.width * frames.height * 1.5; // NV12
1613
+ let pending = Buffer.alloc(0);
1614
+ ff.stdout.on('data', (chunk) => {
1615
+ pending = Buffer.concat([pending, chunk]);
1616
+ while (pending.length >= size) {
1617
+ frames.push(pending.subarray(0, size)); // one buffer, planes back to back
1618
+ pending = pending.subarray(size);
1619
+ }
1620
+ });
1621
+ return () => ff.kill();
1622
+ }
1623
+ ```
1624
+
1625
+ - **Formats**: `'NV12'` (Y, then Cb/Cr pairs — hardware decoders),
1626
+ `'I420'` (Y, Cb, Cr — `yuv420p`, most software decoders) and `'BGRA'`
1627
+ (fourth byte ignored: frames are opaque). `colorSpace` (`'bt709'`
1628
+ default, `'bt601'`, `'bt2020'`) and `range` (`'video'` default, `'full'`)
1629
+ say what YCbCr numbers mean.
1630
+ - **`preferredFormats`** is the display's order, cheapest first:
1631
+ `NV12, I420, BGRA` where something native takes YCbCr as it is — a layer
1632
+ on macOS, a shader on Wayland, an Xv port on X11 — and `BGRA` where the
1633
+ frame is converted in JavaScript. On X11 it is `BGRA`, not kept, for the
1634
+ few round trips until the port answers after the first sink is made.
1635
+ Every format works everywhere.
1636
+ - **The newest frame is the frame**: a push replaces the last, shown or not;
1637
+ nothing queues, and the decoder's clock is the pacing. A push hands its
1638
+ planes over until the next push — they are read then, not copied at the
1639
+ call — so a decoder reusing one buffer pushes it again after writing.
1640
+ - **`push(planes, { strides, time })`** takes a Buffer per plane or one
1641
+ Buffer back to back, with per-plane row bytes when padded; a plane too
1642
+ small is a `TypeError` naming it and the bytes needed. `close()` drops the
1643
+ frame and ignores later pushes, so a decoder racing an unmount loses
1644
+ quietly.
1645
+ - **Nothing to free**: a sink owns nothing native.
1646
+
1647
+ ### Lifted, drawn, and what each costs
1648
+
1649
+ On **macOS** a video goes on its own layer above the window's bitmap when
1650
+ nothing is painted over it, it is not fading and no rounded clipping
1651
+ ancestor holds it ([macos.md](macos.md)); then a `push` costs well under a
1652
+ millisecond and **no window frame**. Otherwise — a tooltip over it, a
1653
+ dialog's dim, a rounded card — it is **drawn**: a push claims the picture,
1654
+ and the next frame composites it in paint order, in the same colours.
1655
+
1656
+ On **X11, Wayland and Windows** it is always drawn, and what differs is how
1657
+ the frame reaches the element's surface:
1658
+
1659
+ - **Wayland**: planes as textures, the matrix in a shader — about 0.2ms of
1660
+ CPU for a 1080p NV12 frame, against 7.6ms converted in JavaScript.
1661
+ - **X11 with an Xv image adaptor** (glamor, under Xorg or Xwayland): the
1662
+ frame goes to the server as decoded, through shared memory, and its GPU
1663
+ converts it into a hidden window copied into the surface — 1.5ms a 1080p
1664
+ NV12 frame against 15ms. Full range, BT.2020, the first frame of a size
1665
+ and a window off screen go the JavaScript way.
1666
+ - **X11 without one, and Windows**: converted to RGBA in JavaScript unless
1667
+ BGRA (hence their preference); X11 uploads through shared memory where
1668
+ available.
1669
+
1670
+ No colour management is applied on any of them. `examples/video.jsx` plays
1671
+ a file through whichever path the display has.
1672
+
1673
+ ### `src` — the platform's player
1674
+
1675
+ Where `useSupports('mediaPlayback')` is true — macOS (AVFoundation), and a
1676
+ `<Frame>` pane whose host plays it ([frame.md](frame.md#sizing-stacking-input))
1677
+ — `src` is a path, `file://` or `http(s)` URL (HLS included), played with
1678
+ the platform's decoder, audio, seeking and streaming.
1679
+
1680
+ ```jsx
1681
+ const ref = useRef(null);
1682
+ <video
1683
+ ref={ref}
1684
+ src="/Users/me/clip.mp4"
1685
+ autoPlay
1686
+ muted
1687
+ onLoadedMetadata={({ duration }) => setDuration(duration)}
1688
+ onTimeUpdate={({ currentTime }) => setAt(currentTime)}
1689
+ onEnded={() => setDone(true)}
1690
+ style={{ width: 640 }}
1691
+ />;
1692
+ // a transport bar of your own
1693
+ <button onPress={() => ref.current.seek(30)} />;
1694
+ ```
1695
+
1696
+ Laid out at 300x150 (or the poster's size) until ready, then at the clip's
1697
+ size from `onLoadedMetadata`. Prop changes go to the same player. The `ref`
1698
+ is HTMLMediaElement's imperative half: `play()` (restarts an ended item),
1699
+ `pause()`, `seek(seconds)` (exact, not keyframe), `currentTime`,
1700
+ `duration`, `paused`. `onPlay`/`onPause` fire as it starts and stops,
1701
+ `onTimeUpdate` four times a second while playing and after a seek,
1702
+ `onEnded` at the end of a non-looping item, `onError` when the item fails.
1703
+ Drawn, the picture is copied at the clip's frame rate; lifted, it costs the
1704
+ window nothing. There is no `controls`: the transport is your React.
1705
+
1706
+ Where it is false — X11 and Wayland for good, Windows today, macOS on an
1707
+ older bridge — the element reports one `NoMediaPlaybackError`
1708
+ (`code: 'ENOMEDIAPLAYBACK'`) through `onError` on a microtask and shows its
1709
+ poster, never a black box. Ask first:
1710
+
1711
+ ```jsx
1712
+ const playback = useSupports('mediaPlayback');
1713
+ {
1714
+ playback ? <video src={file} autoPlay /> : <video frames={decoded} />;
1715
+ }
1716
+ ```
1717
+
1718
+ ## `<canvas>`
1719
+
1720
+ The escape hatch: a retained node you paint.
1721
+
1722
+ ```jsx
1723
+ <canvas
1724
+ style={{ flexGrow: 1 }}
1725
+ onDraw={(ctx, { width, height, node }) => {
1726
+ ctx.fillStyle = 'tomato';
1727
+ ctx.fillRect(0, 0, width / 2, height);
1728
+ }}
1729
+ />
1730
+ ```
1731
+
1732
+ `ctx` is ntk's canvas-like 2d context (paths, transforms, gradients incl.
1733
+ conical, `setLineDash`, round caps/joins, images, text), translated to the
1734
+ node's origin and clipped to it. `onDraw` runs on every repaint of the
1735
+ window. `fillRects` sends many same-colour rectangles (a sparkline, a heat
1736
+ map, terminal cells) as one request, with `fillRect`'s fill style, alpha,
1737
+ composite op and clip:
1738
+
1739
+ ```jsx
1740
+ ctx.fillStyle = theme.accent;
1741
+ ctx.fillRects(bars); // [[x, y, w, h], …] or one flat [x, y, w, h, x, …]
1742
+ ```
1743
+
1744
+ ### Raw pixels — the one call that is not translated
1745
+
1746
+ `putImageData` and `getImageData` ignore the transform _and_ the clip (the
1747
+ HTML canvas rule), so `ctx.putImageData(data, 0, 0)` lands at the
1748
+ **window** corner, silently. `onDraw`'s second argument carries the origin:
1749
+
1750
+ ```jsx
1751
+ <canvas
1752
+ onDraw={(ctx, { width, height, x, y }) => {
1753
+ ctx.putImageData({ width, height, data: rgba }, x, y); // in the node
1754
+ }}
1755
+ style={{ width, height }}
1756
+ />
1757
+ ```
1758
+
1759
+ `x`/`y` are the node's place in the window in a live paint and the cache
1760
+ surface's origin under a [`cacheKey`](#cachekey--draw-it-once), so
1761
+ offsetting by them is right in both. In development an out-of-box
1762
+ `putImageData` warns once.
1763
+
1764
+ For pixels that last, draw an `Image` instead (ntk's, from
1765
+ [`react-x11/ntk`](extending.md#the-subpath-exports)): `drawImage` honours
1766
+ the transform and clip (a half-scrolled canvas stays cut), and the image
1767
+ keeps its server-side pixmap rather than re-uploading every repaint.
1768
+
1769
+ ```jsx
1770
+ import { Image } from 'react-x11/ntk';
1771
+
1772
+ const img = new Image({ width, height, data: rgba }); // keep it memoized
1773
+ <canvas onDraw={(ctx) => ctx.drawImage(img, 0, 0, width, height)} />;
1774
+ ```
1775
+
1776
+ ### `cacheKey` — draw it once
1777
+
1778
+ With a `cacheKey` the canvas is rendered once and composited on later
1779
+ repaints; canvases sharing a key share the copy.
1780
+
1781
+ ```jsx
1782
+ <canvas cacheKey={`spark:${series.id}:${w}x${h}`} onDraw={drawSparkline} />
1783
+ ```
1784
+
1785
+ Opt-in, since `onDraw` is an opaque closure only you can read. **The key
1786
+ must name every input the drawing reads**, or stale pixels show; develop
1787
+ with `REACT_X11_PAINT_CACHE=verify`, which reports exactly that
1788
+ ([Runtime diagnostics](debugging.md#the-paint-cache--react_x11_no_paint_cache-react_x11_paint_cacheverify)).
1789
+ Leave it unset for anything animated or driven by state outside the props.
1790
+ `<svg>` builds its key itself.
1791
+
1792
+ ### `mono` — one colour, and the colour out of the key
1793
+
1794
+ `mono` promises **the drawing is one colour, and not its own to choose**:
1795
+ `fillStyle` and `strokeStyle` arrive preset from the node's resolved
1796
+ `color` — its own, else inherited
1797
+ ([styling.md](styling.md#inheritance-the-ink-the-face-and-the-size)) — so
1798
+ `:hover`, `:disabled` and a theme flip all reach the same `onDraw`:
1799
+
1800
+ ```jsx
1801
+ const chevron = (ctx, { width: w, height: h }) => {
1802
+ ctx.lineWidth = 1.5;
1803
+ ctx.beginPath();
1804
+ ctx.moveTo(w * 0.25, h * 0.375);
1805
+ ctx.lineTo(w * 0.5, h * 0.65);
1806
+ ctx.lineTo(w * 0.75, h * 0.375);
1807
+ ctx.stroke(); // no strokeStyle: the ink is style.color
1808
+ };
1809
+
1810
+ <canvas
1811
+ mono
1812
+ cacheKey="chevron"
1813
+ onDraw={chevron}
1814
+ style={{
1815
+ width: 12,
1816
+ height: 12,
1817
+ color: '$textMuted',
1818
+ ':disabled': { color: '$border' },
1819
+ }}
1820
+ />;
1821
+ ```
1822
+
1823
+ With a `cacheKey`, a `mono` drawing is cached as **coverage** and coloured
1824
+ at composite time, so one copy serves every ink (as `<svg>` does for
1825
+ `currentColor`); without `mono`, each colour is its own rasterization. It
1826
+ works without `cacheKey`, but together is the point. The
1827
+ [system icon set](components.md#system-icons) is twelve of these.
1828
+
1829
+ ---
1830
+
1831
+ ## `<glarea>`
1832
+
1833
+ An OpenGL surface in the layout — on X11 the only drawn element owning a
1834
+ real X window; other backends give it a surface of their own
1835
+ ([gl.md](gl.md)). On X11 under the default `glPolicy` it needs a server with
1836
+ **indirect GLX** (`+iglx` / `AllowIndirectGLX`, off by default on many).
1837
+
1838
+ ```jsx
1839
+ <glarea
1840
+ style={{ flexGrow: 1 }}
1841
+ clearColor="#0b1021"
1842
+ frameLoop="always"
1843
+ onCreated={(gl) => gl.Enable(gl.DEPTH_TEST)}
1844
+ onDraw={(gl, { width, height }) => {
1845
+ gl.MatrixMode(gl.PROJECTION);
1846
+ gl.LoadIdentity();
1847
+ gl.Frustum(-1, 1, -height / width, height / width, 2, 20);
1848
+ // ... immediate-mode or display-list drawing
1849
+ }}
1850
+ />
1851
+ ```
1852
+
1853
+ - `onDraw(gl, { width, height, node })` — draw one frame; the viewport is
1854
+ set and buffers cleared before it, `SwapBuffers` follows.
1855
+ - `onCreated(gl, info)` — once, when the context is current: one-time GL
1856
+ state, texture uploads, display-list compilation.
1857
+ - `clearColor` — CSS colour or `[r, g, b, a]` floats (default black).
1858
+ - `frameLoop` — `'demand'` (default) redraws on prop, size and expose
1859
+ changes; `'always'` renders continuously on ntk's frame clock.
1860
+ - `frameRate` — [`<window frameRate>`](#framerate--pacing-the-frames-under-a-flood)'s
1861
+ values, priced by what `onDraw` and the swap cost; defaults to the owning
1862
+ window's. Name `'display'` for a scene wanting every frame while its
1863
+ window streams; `'always'` under `'adaptive'` loops at the budget.
1864
+ - `glx` — a visual spec for ntk's `chooseGLXConfig`, e.g.
1865
+ `{ DEPTH_SIZE: 24 }`; one query per app, shared by every `<glarea>`.
1866
+ - `onError(err)` — no GL surface (no GLX, no matching visual); unhandled, a
1867
+ console warning.
1868
+
1869
+ On X11's default indirect path `gl` is fixed-function OpenGL 1.4 (immediate
1870
+ mode, matrices, lighting, textures, display lists) — no shaders or vertex
1871
+ arrays, which GLX does not encode. **Put geometry in display lists**: each
1872
+ immediate-mode vertex crosses the wire every frame; a compiled list is one
1873
+ `CallList`. The direct path and Cocoa give `onDraw` a WebGL-shaped table
1874
+ with shaders ([gl.md](gl.md)).
1875
+
1876
+ The surface sits above everything its window paints. **Its own children are
1877
+ drawn above it**: laid out like a `<box>`'s, cut to its box — a legend, a
1878
+ toolbar, a label — and painted on **panes** over the surface in the same
1879
+ frame.
1880
+
1881
+ ```jsx
1882
+ <glarea style={{ flexGrow: 1 }} clearColor="#0e1320" onDraw={drawMap}>
1883
+ <box
1884
+ style={{
1885
+ position: 'absolute',
1886
+ left: 12,
1887
+ top: 12,
1888
+ padding: 8,
1889
+ backgroundColor: '#f4f5f8',
1890
+ }}
1891
+ >
1892
+ <text>Legend</text>
1893
+ </box>
1894
+ </glarea>
1895
+ ```
1896
+
1897
+ - **Cocoa**: one transparent layer over the whole surface, composited by
1898
+ Core Animation — a translucent fill, antialiased edge or shadow blends
1899
+ with the GL frame.
1900
+ - **X11**: one child window per child, stacked above the surface in the
1901
+ children's order and cut (SHAPE) to where the child painted half its
1902
+ alpha or more; the GL frame shows everywhere else. A translucent pixel
1903
+ inside the cut blends with `clearColor`, not the frame — so a translucent
1904
+ fill is tinted by `clearColor`, and no compositor helps. Without SHAPE
1905
+ (`react-x11/test`'s server) a pane is the child's whole rectangle,
1906
+ `clearColor` where unpainted.
1907
+
1908
+ A child that only moves (its `left` or `top`) has its pixels copied, not
1909
+ repainted — only uncovered strips are drawn, and panes it passes over are
1910
+ untouched — so content panned over a scene is cheap; scroll panes among the
1911
+ children scroll the same way.
1912
+ [`REACT_X11_NO_SCROLL_BLIT=1`](debugging.md#react_x11_no_scroll_blit1)
1913
+ turns the copies off.
1914
+
1915
+ `useSupports('glOverlay')` says whether children are drawn: true everywhere
1916
+ but **XQuartz**, where the macOS window server composites every GL surface
1917
+ above all X drawing — children are laid out but not drawn, the pointer over
1918
+ them is the surface's, and development says so once. Draw the overlay
1919
+ beside the surface or in GL there, or use the Cocoa backend.
1920
+
1921
+ Limits worth knowing:
1922
+
1923
+ - **Overlapping surfaces:** both overlays are above both frames on Cocoa;
1924
+ on X11 they stack by creation order.
1925
+ - **Scroll panes:** a surface scrolled partly out of a pane is not clipped
1926
+ by it — its window and panes are real windows at its rect.
1927
+ - **Promotion:** on Cocoa a child is never lifted onto its own layer for an
1928
+ animation (it would sit under the surface); its transitions run on the
1929
+ frame clock.
1930
+
1931
+ **The pointer over the surface is the tree's**, on every backend:
1932
+ `onMouseDown`, `onMouseMove`, `onMouseUp`, `onClick` (double click:
1933
+ `detail: 2`), `onContextMenu`, `onMouseEnter`/`onMouseLeave`,
1934
+ `:hover`/`:active` and `onWheel` fire at the child under the pointer, or the
1935
+ `<glarea>` between children, and bubble. A press focuses the nearest
1936
+ focusable one; `ev.capturePointer()` keeps a drag that leaves the surface.
1937
+ The surface and its children win hit tests over what the tree has behind;
1938
+ `pointerEvents: 'none'` on the surface lets the pointer through.
1939
+
1940
+ ```jsx
1941
+ <glarea
1942
+ onMouseDown={(ev) => {
1943
+ ev.capturePointer();
1944
+ orbit.start(ev.x, ev.y);
1945
+ }}
1946
+ onMouseMove={(ev) => orbit.drag(ev.x, ev.y)}
1947
+ onWheel={(ev) => zoom(ev.deltaY)}
1948
+ />
1949
+ ```
1950
+
1951
+ An element built on `<glarea>` must **not listen for the pointer on
1952
+ `node.window`**: on X11 that diverts every press and wheel from the tree.
1953
+ `node.forwardsPointer` (`GlAreaNode.prototype.forwardsPointer` from
1954
+ `react-x11/node`, to ask without rendering) is `true` where core delivers
1955
+ the pointer — the switch for dropping such a listener.
1956
+
1957
+ A scene graph over `onDraw` is `@react-x11/components/three`. See
1958
+ `examples/viewer3d.jsx` for the raw form, and `examples/labs/gl-overlay.jsx`
1959
+ for a HUD over a surface on both backends, and beside it on XQuartz.
1960
+
1961
+ ---
1962
+
1963
+ ## `<foreign>`
1964
+
1965
+ **X11 only**; no other backend has cross-process window embedding, and
1966
+ none is planned. On Cocoa, Wayland, Windows and the headless mock it lays
1967
+ out as an empty box, draws nothing, never calls `onReady`, and calls
1968
+ `onError` once. Ask first with `useSupports('embedding')`. (`<Frame>`, which
1969
+ uses it on X11 to host a pane of your _own_ app, works on Cocoa and Windows
1970
+ another way — [frame.md](frame.md).)
1971
+
1972
+ Another process's top-level X window, laid out as an element — a terminal
1973
+ pane, a video surface, a docked tray icon:
1974
+
1975
+ ```jsx
1976
+ <foreign
1977
+ windowId={terminal.windowId}
1978
+ style={{ flexGrow: 1, backgroundColor: '#101014' }}
1979
+ onEmbedded={({ xembed }) => setMode(xembed ? 'xembed' : 'reparented')}
1980
+ onClientGone={() => respawn()}
1981
+ />
1982
+ ```
1983
+
1984
+ - `windowId` — the window to embed; changing it hands the old client back
1985
+ first. **Omit it** to adopt whatever is put inside this node, as
1986
+ `xterm -into WID` and `mpv --wid=WID` need.
1987
+ - `onReady({ windowId })` — this node's container id, offered before
1988
+ anything is embedded, so a program can be spawned into it.
1989
+ - `onEmbedded({ id, xembed, version })` — a client is in; `xembed: false`
1990
+ (no `_XEMBED_INFO`, plain reparenting) is the common case.
1991
+ - `onClientGone()` — destroyed, or reparented away by someone else.
1992
+ - `onRequestFocus()` — the client asked for focus; it is given through the
1993
+ focus manager, so the tree observes it normally.
1994
+ - `onError(err)` — the embed failed, or (once, at mount) the backend has no
1995
+ embedding; unhandled, a console warning (for the latter, one per app).
1996
+ - `focusable` — default `true`, like `<textinput>`.
1997
+ - `backgroundColor` in `style` shows while there is no client.
1998
+
1999
+ **Unmount reparents the client back to the root and drops it from the save
2000
+ set; it never destroys it.** Layout treats it as a leaf; its X window
2001
+ follows that rect, and the client gets the synthetic `ConfigureNotify`
2002
+ ICCCM requires, in root coordinates. It stacks above everything drawn in
2003
+ the parent and takes no children, so an overlay belongs in a sibling
2004
+ `<popup>`. Pointer events over it are the client's.
2005
+
2006
+ **While it holds focus, the app's handlers see every key first, and
2007
+ anything they do not `preventDefault()` is forwarded to the client.** That,
2008
+ the XEmbed focus messages, the tab chain crossing the boundary and the one
2009
+ case the rule cannot cover are in [embedding.md](embedding.md).
2010
+
2011
+ ---
2012
+
2013
+ ## 3D scene: `<mesh>`, `<group>`, geometries, materials
2014
+
2015
+ These live in [`@react-x11/components/three`](ecosystem.md), a scene graph
2016
+ with its own reconciler over `<glarea>`, rendering on **both** GL backends
2017
+ from the same JSX. Core keeps the surface and `onDraw` ([gl.md](gl.md)).
2018
+
2019
+ ## Vector drawings
2020
+
2021
+ `<svg>` is a drawing, laid out like an `<image>`: it reports an intrinsic
2022
+ size and scales into the content box it is given. For documents use
2023
+ [`@react-x11/components`](https://github.com/sidorares/react-x11-components)'
2024
+ `<Markdown>` (GFM) and `<Formula>` (KaTeX), with `<RichText>` and
2025
+ `<CodeBlock>` beneath: built from host elements, they select across blocks
2026
+ and re-render only the changed block while content streams in. (The
2027
+ `<markdown>`, `<html>` and `<tex>` elements were removed in 2.0.)
2028
+
2029
+ ### `<svg>`
2030
+
2031
+ ntk `SvgView` (static SVG via Path2D — paths, shapes, gradients,
2032
+ transforms, basic text). Sized like `<image>`: natural `viewBox` size,
2033
+ aspect-preserving when the style sets one of `width`/`height`, scaled into
2034
+ the content box when it sets both. Content is **JSX children, like SVG in
2035
+ React DOM** — camelCase props (`strokeWidth`, `fillRule`; `viewBox` as-is),
2036
+ re-rendered on any prop change:
2037
+
2038
+ ```jsx
2039
+ <svg viewBox="0 0 24 24" style={{ width: 40, height: 40 }}>
2040
+ <circle cx={12} cy={12} r={10} fill={active ? '#2980b9' : '#ccc'} />
2041
+ <path d="M8 12l3 3 5-6" stroke="white" strokeWidth={2} fill="none" />
2042
+ </svg>
2043
+ ```
2044
+
2045
+ | prop | |
2046
+ | --------- | --------------------------------- |
2047
+ | children | declarative SVG elements |
2048
+ | `source` | SVG markup string |
2049
+ | `viewBox` | coordinate system (children form) |
2050
+
2051
+ Children win over `source` when both are given. Unsupported tags are
2052
+ skipped. Per-child event handlers are not dispatched — put them on the
2053
+ `<svg>`.
2054
+
2055
+ **`currentColor`** in `fill`/`stroke` resolves as in CSS: the node's own
2056
+ `color`, else the inherited ink, else the palette's `text`
2057
+ ([styling.md](styling.md#inheritance-the-ink-the-face-and-the-size)) — so
2058
+ one drawing serves every state, and an unstyled one is readable on a dark
2059
+ desktop:
2060
+
2061
+ ```jsx
2062
+ <svg
2063
+ source={icons.gauge}
2064
+ style={{
2065
+ width: 20,
2066
+ height: 20,
2067
+ color: '$fg',
2068
+ ':hover': { color: '$accent' },
2069
+ }}
2070
+ />
2071
+ ```
2072
+
2073
+ A drawing painted entirely in `currentColor`, or in one colour, is cached
2074
+ as coverage: recolouring is a composite, not a re-render, and every colour
2075
+ shares one copy. Two colours or a gradient are baked in.
2076
+ `REACT_X11_DEBUG_PAINT_CACHE=1` shows what is kept.
2077
+
2078
+ Design notes behind this page — mechanism, history, measurements:
2079
+ [design/elements.md](../design/elements.md).