react-x11 1.2.0 → 2.0.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 (158) hide show
  1. package/README.md +330 -81
  2. package/package.json +132 -14
  3. package/src/ClickToComponent.js +9 -2
  4. package/src/DevToolsIntegration.js +440 -32
  5. package/src/Reconciler.js +463 -165
  6. package/src/a11y.js +1299 -0
  7. package/src/acceleratorhooks.js +86 -0
  8. package/src/accelerators.js +128 -0
  9. package/src/activate.js +168 -0
  10. package/src/anchor.js +360 -0
  11. package/src/appcontext.js +161 -0
  12. package/src/appearance.js +748 -0
  13. package/src/appearancehooks.js +96 -0
  14. package/src/apphooks.js +73 -0
  15. package/src/application.js +748 -0
  16. package/src/atspi.js +1953 -0
  17. package/src/bus.js +545 -0
  18. package/src/bushooks.js +136 -0
  19. package/src/clientmessage.js +140 -0
  20. package/src/clipboard.js +121 -0
  21. package/src/components/Button.js +148 -33
  22. package/src/components/Checkbox.js +66 -32
  23. package/src/components/Dialog.js +96 -29
  24. package/src/components/FileDialog.js +491 -0
  25. package/src/components/Icon.js +329 -0
  26. package/src/components/Menu.js +797 -133
  27. package/src/components/PasswordInput.js +434 -0
  28. package/src/components/ProgressBar.js +91 -12
  29. package/src/components/Radio.js +78 -35
  30. package/src/components/Select.js +255 -65
  31. package/src/components/Slider.js +102 -41
  32. package/src/components/SplitPane.js +206 -0
  33. package/src/components/Switch.js +100 -22
  34. package/src/components/Table.js +529 -0
  35. package/src/components/Tabs.js +211 -0
  36. package/src/components/Tooltip.js +528 -54
  37. package/src/components/anchor.js +154 -107
  38. package/src/components/change.js +34 -0
  39. package/src/components/dnd.js +103 -0
  40. package/src/components/index.js +16 -3
  41. package/src/components/keys.js +17 -20
  42. package/src/components/scribble.js +247 -0
  43. package/src/components/theme.js +350 -42
  44. package/src/components/typeahead.js +12 -2
  45. package/src/compose.js +868 -0
  46. package/src/compositing.js +224 -0
  47. package/src/dbusmenu.js +384 -0
  48. package/src/debug.d.ts +63 -0
  49. package/src/debug.js +642 -0
  50. package/src/decorations.js +486 -0
  51. package/src/desktopsettings.js +209 -0
  52. package/src/desktopsettingshooks.js +62 -0
  53. package/src/dnd.js +1725 -0
  54. package/src/editmenu.js +272 -0
  55. package/src/errors.js +98 -0
  56. package/src/events.js +1406 -162
  57. package/src/extensions.js +45 -0
  58. package/src/filedialog.js +375 -0
  59. package/src/filedialoghooks.js +132 -0
  60. package/src/fonthooks.js +64 -0
  61. package/src/fonts.js +301 -0
  62. package/src/foreignnodes.js +519 -0
  63. package/src/frame/child.js +33 -0
  64. package/src/frame/childmain.js +274 -0
  65. package/src/frame/env.js +140 -0
  66. package/src/frame/index.js +444 -0
  67. package/src/frame/lifecycle.js +67 -0
  68. package/src/frame/protocol.js +179 -0
  69. package/src/frames.js +73 -0
  70. package/src/glbackend.js +114 -0
  71. package/src/glnodes.js +125 -67
  72. package/src/globalmenu.js +683 -0
  73. package/src/host.d.ts +88 -0
  74. package/src/host.js +42 -0
  75. package/src/idle.js +490 -0
  76. package/src/idlehooks.js +100 -0
  77. package/src/imagesource.js +349 -0
  78. package/src/index.d.ts +475 -0
  79. package/src/index.js +83 -9
  80. package/src/inputtime.js +206 -0
  81. package/src/jsx-dev-runtime.d.ts +21 -0
  82. package/src/jsx-dev-runtime.js +2 -0
  83. package/src/jsx-runtime.d.ts +39 -0
  84. package/src/jsx-runtime.js +6 -0
  85. package/src/keyboard.js +256 -0
  86. package/src/keyboardstate.js +278 -0
  87. package/src/keyboardstatehooks.js +58 -0
  88. package/src/keysyms.d.ts +149 -0
  89. package/src/keysyms.js +270 -0
  90. package/src/locale.js +170 -0
  91. package/src/localehooks.js +47 -0
  92. package/src/menuitem.js +223 -0
  93. package/src/node.d.ts +597 -0
  94. package/src/node.js +44 -0
  95. package/src/nodes.js +9546 -690
  96. package/src/ntk.d.ts +44 -0
  97. package/src/ntk.js +25 -0
  98. package/src/paintcache.js +366 -0
  99. package/src/palette.js +380 -0
  100. package/src/pastestate.js +66 -0
  101. package/src/portal.js +461 -0
  102. package/src/priority.js +26 -0
  103. package/src/refresh/index.d.ts +40 -0
  104. package/src/refresh/index.js +122 -0
  105. package/src/refresh/loader.d.ts +37 -0
  106. package/src/refresh/loader.js +401 -0
  107. package/src/refresh/register.d.ts +5 -0
  108. package/src/refresh/register.js +13 -0
  109. package/src/registry.js +232 -0
  110. package/src/scale.js +626 -0
  111. package/src/scalehooks.js +27 -0
  112. package/src/screencolor.js +640 -0
  113. package/src/screencolorhooks.js +101 -0
  114. package/src/screens.js +754 -0
  115. package/src/screenshooks.js +137 -0
  116. package/src/startup.js +302 -0
  117. package/src/style.d.ts +126 -0
  118. package/src/style.js +33 -0
  119. package/src/styles.js +1482 -6
  120. package/src/svgnodes.js +306 -0
  121. package/src/testing/a11y.js +484 -0
  122. package/src/testing/components.js +414 -0
  123. package/src/testing/events.js +407 -0
  124. package/src/testing/harness.js +455 -0
  125. package/src/testing/index.d.ts +558 -0
  126. package/src/testing/index.js +85 -0
  127. package/src/testing/mock-app.js +463 -0
  128. package/src/testing/pixels.js +152 -0
  129. package/src/testing/queries.js +224 -0
  130. package/src/textrange.js +83 -0
  131. package/src/textselection.js +439 -0
  132. package/src/trace-registry.js +63 -0
  133. package/src/transfer.js +93 -0
  134. package/src/types/appearance.d.ts +84 -0
  135. package/src/types/application.d.ts +173 -0
  136. package/src/types/components.d.ts +820 -0
  137. package/src/types/dbus.d.ts +177 -0
  138. package/src/types/elements.d.ts +897 -0
  139. package/src/types/events.d.ts +549 -0
  140. package/src/types/filedialog.d.ts +260 -0
  141. package/src/types/fonts.d.ts +124 -0
  142. package/src/types/frame.d.ts +146 -0
  143. package/src/types/globalmenu.d.ts +42 -0
  144. package/src/types/nodes.d.ts +199 -0
  145. package/src/types/screencolor.d.ts +84 -0
  146. package/src/types/style.d.ts +434 -0
  147. package/src/types/system.d.ts +287 -0
  148. package/src/windowid.js +151 -0
  149. package/src/windowstate.js +393 -0
  150. package/src/xsettings.js +336 -0
  151. package/src/yoga.d.ts +55 -0
  152. package/src/yoga.js +135 -0
  153. package/src/components/Canvas3D.js +0 -28
  154. package/src/geometry3d.js +0 -223
  155. package/src/pointer3d.js +0 -158
  156. package/src/raycast3d.js +0 -146
  157. package/src/richnodes.js +0 -436
  158. package/src/scene3d.js +0 -683
@@ -2,15 +2,27 @@
2
2
  // support needed. Plain createElement (no JSX) so the library stays
3
3
  // build-step-free for consumers.
4
4
 
5
- import React, { useEffect, useRef, useState } from 'react';
6
- import { useTheme } from './theme.js';
5
+ import React, {
6
+ useCallback,
7
+ useEffect,
8
+ useLayoutEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from 'react';
13
+ import { useApp, useSupports } from '../appcontext.js';
14
+ import { interpolate } from '../styles.js';
15
+ import { capTrim, ThemeProvider, useTheme } from './theme.js';
7
16
  import {
8
17
  DEFAULT_LABEL_SIZE,
18
+ anchorArea,
9
19
  measureLabel,
10
20
  movingToward,
11
21
  SAFE_HOVER_DELAY,
12
22
  screenPoint,
23
+ screenRect,
13
24
  useAnchor,
25
+ useAnchorTracking,
14
26
  } from './anchor.js';
15
27
 
16
28
  const h = React.createElement;
@@ -19,43 +31,299 @@ const TOOLTIP_PADDING_X = 8;
19
31
 
20
32
  const TOOLTIP_PADDING_Y = 4;
21
33
 
34
+ // The arrow, across the bubble's edge and out from it. Small on purpose: it
35
+ // is there to say *which* control the hint belongs to, and a big one starts
36
+ // to look like a speech balloon.
37
+ const ARROW_WIDTH = 12;
38
+ const ARROW_DEPTH = 6;
39
+
40
+ // The arrow's base sits this far *inside* the bubble. The two shapes are
41
+ // painted separately and antialiased separately, so meeting them exactly on
42
+ // the edge leaves a hairline of half-covered pixels between them.
43
+ const ARROW_OVERLAP = 1;
44
+
45
+ // There is no default size for a `label` that is not text: the popup is
46
+ // rendered once **hidden** at its natural size — a real layout of the real
47
+ // content, never on screen — and placed from what that measured. See
48
+ // `surface()` for the two lives, and the measuring effect for the order.
49
+
50
+ const isText = (label) =>
51
+ typeof label === 'string' || typeof label === 'number';
52
+
53
+ // Which sides `direction="auto"` tries, in order. Above first, because a
54
+ // hint above the thing it describes covers nothing you are about to click;
55
+ // then below, then out to the sides, which is where a control near the top
56
+ // or bottom of a screen has to put it.
57
+ const AUTO_SIDES = ['top', 'bottom', 'right', 'left'];
58
+
59
+ // Room the popup needs beyond its own size for a side to count as fitting:
60
+ // `anchorRect`'s own 2px gap, and enough left over that the hint is not
61
+ // jammed flush against the edge of the screen.
62
+ const AUTO_MARGIN = 8;
63
+
64
+ /**
65
+ * The palette **inside** the bubble, which is the one outside it upside
66
+ * down: a tooltip is drawn in the palette's ink so that it reads as a label
67
+ * over the desktop rather than as another panel of the app, and that makes
68
+ * its surface `text` and its own ink `background`.
69
+ *
70
+ * Content has to be told, or it cannot be written once and stay legible in
71
+ * both schemes — a card that hard-codes light text is invisible on the light
72
+ * bubble a dark palette gives it, which is what a naive component label
73
+ * does. Published through `ThemeProvider`, so both routes agree: a `$token`
74
+ * in the content and a `useTheme()` in it describe the surface the content
75
+ * is actually on.
76
+ *
77
+ * `textMuted` is derived rather than swapped, because the palette's own is a mid
78
+ * grey chosen against the *app's* background. Mixing the bubble's ink
79
+ * towards its surface lands on the muted version of whichever ink this
80
+ * turned out to be.
81
+ */
82
+ const MUTED = 0.38;
83
+ const BORDERISH = 0.6;
84
+
85
+ // The provider's box fills the bubble and centres what is in it, so a
86
+ // string label still sits in the middle and an element with `flexGrow: 1`
87
+ // still gets the whole rectangle.
88
+ const BUBBLE_CONTENT = Object.freeze({
89
+ flexGrow: 1,
90
+ justifyContent: 'center',
91
+ });
92
+
93
+ function invertedSurface(theme) {
94
+ return {
95
+ background: theme.text,
96
+ // The bubble is both at once for what is inside it: there is nothing
97
+ // raised off a tooltip, and a card in one that reached for `$surface`
98
+ // would otherwise take the *app's* surface and light up.
99
+ surface: theme.text,
100
+ text: theme.background,
101
+ textMuted:
102
+ interpolate(theme.background, theme.text, MUTED) ?? theme.textMuted,
103
+ border:
104
+ interpolate(theme.background, theme.text, BORDERISH) ?? theme.border,
105
+ };
106
+ }
107
+
22
108
  /**
23
- * <Tooltip label placement delay>…</Tooltip> a hover hint in a `<popup>`,
109
+ * The one hint that is up, per connection.
110
+ *
111
+ * A tooltip belongs to where the pointer is, and there is one pointer — so
112
+ * two of them on screen at once is never a state anything meant to produce.
113
+ * It happens anyway without a rule like this, because each `Tooltip` only
114
+ * watches its own trigger: the safe-polygon grace that lets a hint with
115
+ * content in it be *reached* (docs/components.md) is exactly a window where
116
+ * one stays up while the pointer has already moved on, and moving on can
117
+ * mean arriving somewhere that shows another.
118
+ *
119
+ * So the trigger taking the hover dismisses whatever else is showing, and
120
+ * showing claims the slot. Keyed by connection, not module-global: one
121
+ * process can drive several roots on several displays, and each display has
122
+ * its own pointer. A WeakMap, so a closed connection takes its entry with
123
+ * it.
124
+ */
125
+ const showing = new WeakMap();
126
+
127
+ /** Hide the hint that is up, unless it is this one. */
128
+ function dismissOthers(app, self) {
129
+ const current = showing.get(app);
130
+ if (!current || current === self) return;
131
+ showing.delete(app);
132
+ current();
133
+ }
134
+
135
+ /**
136
+ * The side to hang a hint off when the caller has not named one.
137
+ *
138
+ * Measured against the **screen**, not the owner window: a popup is a real
139
+ * X window, so the room a tooltip has is the room the display has — the
140
+ * usable part of the monitor the trigger is on (`anchorArea`). The first
141
+ * side it fits on wins, in the order above, and if it fits nowhere the
142
+ * roomiest side does — there the placement is going to be clamped whatever
143
+ * we pick, and the most room is the least clamping.
144
+ *
145
+ * With no display to ask at all this is `'top'`, which is where a tooltip
146
+ * went before there was anything to ask.
147
+ */
148
+ function autoDirection(node, size) {
149
+ const trigger = screenRect(node);
150
+ const area = anchorArea(node);
151
+ if (!trigger || !area) return 'top';
152
+ const room = {
153
+ top: trigger.y - area.y,
154
+ bottom: area.y + area.height - (trigger.y + trigger.height),
155
+ left: trigger.x - area.x,
156
+ right: area.x + area.width - (trigger.x + trigger.width),
157
+ };
158
+ const needed = (side) =>
159
+ (side === 'top' || side === 'bottom' ? size.height : size.width) +
160
+ AUTO_MARGIN;
161
+ const fits = AUTO_SIDES.find((side) => room[side] >= needed(side));
162
+ if (fits) return fits;
163
+ return AUTO_SIDES.reduce((best, side) =>
164
+ room[side] > room[best] ? side : best,
165
+ );
166
+ }
167
+
168
+ /**
169
+ * Where the arrow goes **inside the popup window**, and which way it points.
170
+ *
171
+ * Along the bubble's edge it wants the middle of the trigger, not the middle
172
+ * of the bubble — the two are the same until a screen edge slides the popup
173
+ * and leaves the trigger where it was. Clamped so the triangle stays on the
174
+ * straight part of the edge: pushed into a rounded corner it would grow a
175
+ * notch where the two curves cross.
176
+ */
177
+ function arrowPlacement(side, rect, trigger, radius) {
178
+ const vertical = side === 'top' || side === 'bottom';
179
+ const span = vertical ? rect.width : rect.height;
180
+ // the middle of the trigger, in the popup's own coordinates
181
+ const wanted = !trigger
182
+ ? span / 2
183
+ : vertical
184
+ ? trigger.x - rect.x + trigger.width / 2
185
+ : trigger.y - rect.y + trigger.height / 2;
186
+ const margin = radius + ARROW_WIDTH / 2 + 1;
187
+ const center = Math.round(Math.max(margin, Math.min(span - margin, wanted)));
188
+ // how far along the popup the bubble's outer edge is, on the axis the
189
+ // arrow sticks out along
190
+ const reach = (vertical ? rect.height : rect.width) - ARROW_DEPTH;
191
+
192
+ const across = center - ARROW_WIDTH / 2;
193
+ const out = reach - ARROW_OVERLAP;
194
+ const thick = ARROW_DEPTH + ARROW_OVERLAP;
195
+ if (side === 'top') {
196
+ return { left: across, top: out, width: ARROW_WIDTH, height: thick, side };
197
+ }
198
+ if (side === 'bottom') {
199
+ return { left: across, top: 0, width: ARROW_WIDTH, height: thick, side };
200
+ }
201
+ if (side === 'left') {
202
+ return { left: out, top: across, width: thick, height: ARROW_WIDTH, side };
203
+ }
204
+ return { left: 0, top: across, width: thick, height: ARROW_WIDTH, side };
205
+ }
206
+
207
+ /** The triangle itself, pointing away from the bubble it grows out of. */
208
+ function paintArrow(ctx, { width, height }, side, color) {
209
+ ctx.fillStyle = color;
210
+ ctx.beginPath();
211
+ if (side === 'top') {
212
+ ctx.moveTo(0, 0);
213
+ ctx.lineTo(width, 0);
214
+ ctx.lineTo(width / 2, height);
215
+ } else if (side === 'bottom') {
216
+ ctx.moveTo(0, height);
217
+ ctx.lineTo(width, height);
218
+ ctx.lineTo(width / 2, 0);
219
+ } else if (side === 'left') {
220
+ ctx.moveTo(0, 0);
221
+ ctx.lineTo(0, height);
222
+ ctx.lineTo(width, height / 2);
223
+ } else {
224
+ ctx.moveTo(width, 0);
225
+ ctx.lineTo(width, height);
226
+ ctx.lineTo(0, height / 2);
227
+ }
228
+ ctx.closePath();
229
+ ctx.fill();
230
+ }
231
+
232
+ /**
233
+ * <Tooltip label direction delay>…</Tooltip> — a hover hint in a `<popup>`,
24
234
  * so it can extend past the owner window's bounds.
25
235
  *
26
236
  * Wraps its children in a row box that carries the hover handlers and the
27
237
  * anchor ref. Shows after `delay` ms of hover, hides immediately on leave
28
238
  * (and on mousedown — a tooltip lingering over a menu you just opened is
29
- * the classic annoyance). `placement` flips automatically near a screen
30
- * edge, via the same `useAnchor` math `Select` uses.
239
+ * the classic annoyance).
240
+ *
241
+ * `direction` is which side of the trigger it goes on, and it defaults to
242
+ * `'auto'`: the first side the hint fits on, preferring above. Naming one —
243
+ * `'top'`, `'bottom'`, `'left'`, `'right'` — is a preference rather than a
244
+ * promise either way, since a named side still flips to its opposite rather
245
+ * than opening off-screen, via the same `useAnchor` math `Select` uses.
246
+ * (`placement` is the older name for the same thing and still wins where it
247
+ * is given, so that nothing that named a side has to change.)
31
248
  *
32
- * The popup is sized from the measured label, since a `<popup>` is a real
33
- * X window and needs its size up front rather than after layout.
249
+ * `label` is usually a string, and then the popup is sized from the
250
+ * **measured** text synchronously, because `anchorRect` needs the size to
251
+ * decide which side of the trigger the hint fits on, and that is decided
252
+ * during the render that opens it. It can equally be an element — a swatch
253
+ * and a hex code, a shortcut in its own type, a whole card — and then the
254
+ * content is **measured too**: the popup is rendered once `hidden` at its
255
+ * natural size (a real layout of the real content, capped at the screen and
256
+ * at `maxWidth`/`maxHeight`), the size is read back, and the same placement
257
+ * math runs before anything is mapped — so a card whose height depends on
258
+ * what is in it gets the bubble it needs, not a guess (issue #368). Both
259
+ * commits land in the same task and the popup maps last, so it is only ever
260
+ * on screen placed and at its final size. `width`/`height` still pin an
261
+ * axis exactly — give both and the measuring pass is skipped entirely; give
262
+ * one and the other is measured for it, so `width={340}` is a fixed column
263
+ * whose height fits the message. The element fills the bubble and draws its
264
+ * own padding; a string gets the bubble's. The size is settled at open —
265
+ * content that changes size while the hint is up is clipped or short until
266
+ * the next open, which is a hint's lifetime away.
267
+ *
268
+ * On a display that composites, the popup is a real ARGB window: a rounded
269
+ * bubble with a small arrow pointing back at the trigger, and everything the
270
+ * two do not cover left empty. Without a compositor it degrades to the
271
+ * square opaque rectangle it has always been — an arrow there would be a
272
+ * black triangle in a black notch, which is worse than no arrow.
34
273
  */
35
274
  export function Tooltip({
36
275
  label,
37
276
  children,
38
- placement = 'top',
277
+ direction = 'auto',
278
+ placement,
39
279
  delay = 500,
40
280
  fontSize = DEFAULT_LABEL_SIZE,
281
+ width,
282
+ height,
283
+ maxWidth,
284
+ maxHeight,
285
+ style,
41
286
  ...boxProps
42
287
  }) {
43
288
  const theme = useTheme();
289
+ const app = useApp();
44
290
  const ref = useRef(null);
291
+ const popup = useRef(null);
45
292
  const measureAnchor = useAnchor(ref);
46
293
  const [rect, setRect] = useState(null);
294
+ // An element label's measured size — what the hidden pass below produced,
295
+ // and what placement reads for as long as the hint is up. Text is
296
+ // measured synchronously instead and never lands here.
297
+ const [bubble, setBubble] = useState(null);
298
+ const [measuring, setMeasuring] = useState(false);
47
299
  const timer = useRef(null);
300
+ // Whether there is an arrow at all has to be settled *before* the popup
301
+ // exists — it is part of how big the window is — so this is the display
302
+ // question (`useSupports`) rather than the per-window style block.
303
+ const composited = useSupports('transparency');
304
+ // memoized for identity, not for the arithmetic: a fresh palette every
305
+ // render would re-resolve every `$token` under it
306
+ const surfacePalette = useMemo(() => invertedSurface(theme), [theme]);
48
307
 
49
- const cancel = () => {
308
+ // `cancel` and `hide` touch nothing but refs and a state setter, so they
309
+ // can hold still across renders — and `hide` has to: it is this tooltip's
310
+ // identity in the `showing` registry, and one that changed every render
311
+ // would leave the registry holding a stale closure.
312
+ const cancel = useCallback(() => {
50
313
  if (timer.current) {
51
314
  clearTimeout(timer.current);
52
315
  timer.current = null;
53
316
  }
54
- };
55
- const hide = () => {
317
+ }, []);
318
+ const hide = useCallback(() => {
56
319
  cancel();
320
+ if (showing.get(app) === hide) showing.delete(app);
57
321
  setRect(null);
58
- };
322
+ // the measurement goes with the hint: the next open re-measures, so a
323
+ // label that changed while nothing showed opens at its new size
324
+ setBubble(null);
325
+ setMeasuring(false);
326
+ }, [app, cancel]);
59
327
 
60
328
  // safe-polygon hover (docs/components.md): leaving the trigger *toward*
61
329
  // the tooltip keeps it up, so a tooltip with content in it can be
@@ -65,7 +333,7 @@ export function Tooltip({
65
333
  cancel();
66
334
  timer.current = setTimeout(() => {
67
335
  timer.current = null;
68
- setRect(null);
336
+ hide();
69
337
  }, SAFE_HOVER_DELAY);
70
338
  };
71
339
  const onMouseMove = (ev) => {
@@ -76,20 +344,119 @@ export function Tooltip({
76
344
  else hide();
77
345
  };
78
346
 
79
- // a pending timer must not outlive the component
80
- useEffect(() => cancel, []);
347
+ // neither a pending timer nor a claim on the one visible hint may outlive
348
+ // the component
349
+ useEffect(() => {
350
+ return () => {
351
+ cancel();
352
+ if (showing.get(app) === hide) showing.delete(app);
353
+ };
354
+ }, [app, cancel, hide]);
81
355
 
82
- const show = () => {
356
+ // `!label` would have thrown away `0`, which is a hint like any other;
357
+ // an empty string is not one
358
+ const hasLabel = !(label == null || label === false || label === '');
359
+
360
+ // The bubble, which is the popup minus whatever the arrow takes: measured
361
+ // text for a string, the caller's numbers where both were given, and the
362
+ // hidden pass's answer for an element — null until that pass has run.
363
+ const bubbleSize = (node) => {
364
+ if (isText(label)) {
365
+ const text = measureLabel(node, label, { size: fontSize });
366
+ return {
367
+ width: width ?? Math.ceil(text.width) + TOOLTIP_PADDING_X * 2,
368
+ height: height ?? Math.ceil(text.height) + TOOLTIP_PADDING_Y * 2,
369
+ };
370
+ }
371
+ if (width != null && height != null) return { width, height };
372
+ return bubble;
373
+ };
374
+
375
+ // The anchor options for a bubble of this size: which side of the trigger,
376
+ // and the popup grown by the arrow along the axis that faces it.
377
+ const optionsFor = (node, size) => {
378
+ const grow = composited ? ARROW_DEPTH : 0;
379
+ const want = placement ?? direction;
380
+ const side =
381
+ want === 'auto'
382
+ ? autoDirection(node, {
383
+ width: size.width + grow,
384
+ height: size.height + grow,
385
+ })
386
+ : want;
387
+ const vertical = side === 'top' || side === 'bottom';
388
+ return {
389
+ placement: side,
390
+ align: 'center',
391
+ width: size.width + (vertical ? 0 : grow),
392
+ height: size.height + (vertical ? grow : 0),
393
+ };
394
+ };
395
+
396
+ const tooltipAnchorOptions = () => {
83
397
  const node = ref.current;
84
- if (!node || !label) return;
85
- const text = measureLabel(node, label, { size: fontSize });
86
- const width = Math.ceil(text.width) + TOOLTIP_PADDING_X * 2 + 2;
87
- const height = Math.ceil(text.height) + TOOLTIP_PADDING_Y * 2 + 2;
88
- const next = measureAnchor({ placement, align: 'center', width, height });
89
- if (next) setRect(next);
398
+ if (!node || !hasLabel) return null;
399
+ const size = bubbleSize(node);
400
+ if (!size) return null;
401
+ return optionsFor(node, size);
90
402
  };
91
403
 
404
+ const place = (options) => {
405
+ const next = measureAnchor(options);
406
+ if (!next) return;
407
+ // claim the slot at the moment there is something to see, so a hint
408
+ // that never made it past its delay never took anything away
409
+ dismissOthers(app, hide);
410
+ showing.set(app, hide);
411
+ setRect(next);
412
+ };
413
+
414
+ const show = () => {
415
+ if (!ref.current || !hasLabel) return;
416
+ const options = tooltipAnchorOptions();
417
+ // No options with a node and a label means an element label whose size
418
+ // is not known yet: render the popup hidden at its natural size, and
419
+ // the measuring effect below finishes the job.
420
+ if (options) place(options);
421
+ else setMeasuring(true);
422
+ };
423
+
424
+ // The second half of opening an element label: the commit that set
425
+ // `measuring` rendered the popup hidden — realized, laid out, never
426
+ // mapped — so its natural size is now a fact. Read it back, run the same
427
+ // placement `show()` runs, and re-render placed; a layout effect, so both
428
+ // commits land in the same task, nothing paints in between, and the popup
429
+ // maps only once it is the right size in the right place.
430
+ useLayoutEffect(() => {
431
+ if (!measuring) return;
432
+ setMeasuring(false);
433
+ const wnd = popup.current;
434
+ const node = ref.current;
435
+ if (!wnd || !node) return;
436
+ const size = { width: wnd.width, height: wnd.height };
437
+ setBubble(size);
438
+ place(optionsFor(node, size));
439
+ });
440
+
441
+ // keeps the tooltip pinned to its trigger for as long as it is shown: a
442
+ // scrolled ancestor, the trigger's own layout moving it, or the owner
443
+ // window being nudged by the window manager or a script would otherwise
444
+ // leave it pointing at empty space. If the trigger scrolls out of view
445
+ // entirely, the tooltip hides instead of following it there — unlike a
446
+ // web tooltip it is a real window and can drift past the edge of the
447
+ // document it annotates, which reads as a stray popup rather than a hint.
448
+ useAnchorTracking(ref, Boolean(rect), tooltipAnchorOptions, setRect, hide);
449
+
92
450
  const onMouseEnter = () => {
451
+ // The pointer has arrived somewhere else, so whatever is still up
452
+ // belongs to where it *was* — drop it now rather than at the end of
453
+ // this one's delay, which would leave two on screen for half a second
454
+ // saying different things about the same pointer.
455
+ //
456
+ // This does not fight the safe polygon. A trigger under an open hint
457
+ // cannot be hovered — the popup is a window above it — so reaching for
458
+ // a hint never crosses the trigger this would fire for.
459
+ dismissOthers(app, hide);
93
460
  cancel();
94
461
  timer.current = setTimeout(() => {
95
462
  timer.current = null;
@@ -97,49 +464,156 @@ export function Tooltip({
97
464
  }, delay);
98
465
  };
99
466
 
467
+ /**
468
+ * The popup, in its two lives.
469
+ *
470
+ * **Measuring** (no `rect` yet): `hidden` — realized and laid out, never
471
+ * mapped — with the bubble in flow and no arrow, so the popup's natural
472
+ * size *is* the bubble's. One commit later the effect above has read it
473
+ * back and placed it; nothing is ever on screen unplaced.
474
+ *
475
+ * **Placed**: two absolutely-positioned pieces, the bubble and the arrow
476
+ * beside it. Absolute rather than a flex column, because the two are
477
+ * placed by the same arithmetic that decided how big the window is, and
478
+ * that arithmetic has to run before the window is on screen. Whatever
479
+ * neither covers is the transparent margin the arrow needs to point
480
+ * through.
481
+ *
482
+ * One `<popup>` element for both, so the transition is a configure and a
483
+ * map on the same X window — never a second window — and the label's
484
+ * component instances (their state, their subscriptions) survive from the
485
+ * layout they were measured in to the one that shows them.
486
+ */
487
+ const surface = () => {
488
+ // the side `anchorRect` settled on, which is the one it was asked for
489
+ // unless a screen edge flipped it
490
+ const side = rect ? (rect.placement ?? 'top') : null;
491
+ const vertical = side === 'top' || side === 'bottom';
492
+ const arrow =
493
+ rect && composited
494
+ ? arrowPlacement(
495
+ side,
496
+ rect,
497
+ screenRect(ref.current),
498
+ theme.radiusTooltip,
499
+ )
500
+ : null;
501
+ const inset = arrow ? ARROW_DEPTH : 0;
502
+ const bubbleRect = rect && {
503
+ // the arrow is always on the side facing the trigger, so the bubble is
504
+ // pushed off that edge and fills the rest
505
+ left: side === 'right' ? inset : 0,
506
+ top: side === 'bottom' ? inset : 0,
507
+ width: rect.width - (vertical ? 0 : inset),
508
+ height: rect.height - (vertical ? inset : 0),
509
+ };
510
+
511
+ return h(
512
+ 'popup',
513
+ {
514
+ ref: popup,
515
+ theme,
516
+ windowType: 'tooltip',
517
+ transparent: true,
518
+ ...(rect
519
+ ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
520
+ : {
521
+ // measuring: never mapped, so the position is nobody's; the
522
+ // axes the caller pinned stay pinned and the rest are
523
+ // measured, which is what makes `width` alone a fixed column
524
+ // whose height fits the content
525
+ hidden: true,
526
+ x: 0,
527
+ y: 0,
528
+ width: width ?? 'auto',
529
+ height: height ?? 'auto',
530
+ }),
531
+ // in both lives, so the props do not churn between them — inert on
532
+ // an exact axis, the measurement's cap on an auto one
533
+ ...(maxWidth != null ? { maxWidth } : null),
534
+ ...(maxHeight != null ? { maxHeight } : null),
535
+ style: {
536
+ backgroundColor: theme.text,
537
+ '@supports transparency': { backgroundColor: 'transparent' },
538
+ },
539
+ },
540
+ h(
541
+ 'box',
542
+ {
543
+ role: 'tooltip',
544
+ onMouseEnter: cancel,
545
+ onMouseLeave: hide,
546
+ style: [
547
+ bubbleRect
548
+ ? {
549
+ position: 'absolute',
550
+ left: bubbleRect.left,
551
+ top: bubbleRect.top,
552
+ width: bubbleRect.width,
553
+ height: bubbleRect.height,
554
+ }
555
+ : // measuring: in flow, so the popup takes the bubble's
556
+ // natural size
557
+ null,
558
+ {
559
+ justifyContent: 'center',
560
+ backgroundColor: theme.text,
561
+ // No border. On a hint this small a 1px outline in a colour
562
+ // close to the fill is a smudge on the corners and nothing
563
+ // anywhere else; the arrow is what tells you where it belongs.
564
+ ...(isText(label)
565
+ ? {
566
+ paddingLeft: TOOLTIP_PADDING_X,
567
+ paddingRight: TOOLTIP_PADDING_X,
568
+ }
569
+ : null),
570
+ '@supports transparency': { borderRadius: theme.radiusTooltip },
571
+ },
572
+ ],
573
+ },
574
+ // the content is on the inverted surface, and is given the palette
575
+ // that says so — both routes at once, which is what `ThemeProvider`
576
+ // is for. The filling box it plants keeps a string label centred.
577
+ h(
578
+ ThemeProvider,
579
+ { value: surfacePalette, style: BUBBLE_CONTENT },
580
+ isText(label)
581
+ ? h(
582
+ 'text',
583
+ { style: [capTrim, { color: '$text', fontSize }] },
584
+ label,
585
+ )
586
+ : label,
587
+ ),
588
+ ),
589
+ arrow &&
590
+ h('canvas', {
591
+ style: {
592
+ position: 'absolute',
593
+ left: arrow.left,
594
+ top: arrow.top,
595
+ width: arrow.width,
596
+ height: arrow.height,
597
+ },
598
+ onDraw: (ctx, info) => paintArrow(ctx, info, arrow.side, theme.text),
599
+ }),
600
+ );
601
+ };
602
+
100
603
  return h(
101
604
  'box',
102
605
  {
606
+ theme,
103
607
  ref,
104
- flexDirection: 'row',
105
- alignItems: 'center',
106
608
  onMouseEnter,
107
609
  onMouseMove,
108
610
  onMouseLeave,
109
611
  onMouseDown: hide,
110
612
  ...boxProps,
613
+ style: [{ flexDirection: 'row', alignItems: 'center' }, style],
111
614
  },
112
615
  children,
113
- rect &&
114
- h(
115
- 'popup',
116
- {
117
- x: rect.x,
118
- y: rect.y,
119
- width: rect.width,
120
- height: rect.height,
121
- windowType: 'tooltip',
122
- backgroundColor: theme.text,
123
- },
124
- h(
125
- 'box',
126
- {
127
- // the pointer reaching the tooltip keeps it up; leaving it
128
- // dismisses, as if the trigger had been left
129
- onMouseEnter: cancel,
130
- onMouseLeave: hide,
131
- flexGrow: 1,
132
- borderWidth: 1,
133
- borderColor: theme.text,
134
- borderRadius: 3,
135
- backgroundColor: theme.text,
136
- justifyContent: 'center',
137
- paddingLeft: TOOLTIP_PADDING_X,
138
- paddingRight: TOOLTIP_PADDING_X,
139
- },
140
- h('text', { color: theme.background, fontSize }, label),
141
- ),
142
- ),
616
+ (rect || measuring) && surface(),
143
617
  );
144
618
  }
145
619