react-x11 2.15.3 → 2.16.1

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 (67) hide show
  1. package/README.md +37 -0
  2. package/package.json +3 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/acceleratorhooks.js +40 -6
  5. package/src/anchor.js +79 -19
  6. package/src/capabilities.js +29 -4
  7. package/src/cocoa/app.js +211 -11
  8. package/src/cocoa/context2d.js +23 -0
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/desktopcapabilityhooks.js +29 -6
  22. package/src/filedialoghooks.js +3 -5
  23. package/src/frame/childmain.js +8 -20
  24. package/src/frame/env.js +2 -10
  25. package/src/icontheme.js +240 -0
  26. package/src/imagesource.js +83 -1
  27. package/src/index.d.ts +10 -1
  28. package/src/index.js +3 -0
  29. package/src/keysymchars.js +47 -0
  30. package/src/keysyms.d.ts +19 -1
  31. package/src/keysyms.js +107 -8
  32. package/src/node.d.ts +7 -0
  33. package/src/nodes/animation.js +17 -47
  34. package/src/nodes/cascade.js +17 -2
  35. package/src/nodes/image.js +63 -1
  36. package/src/nodes/kinds.js +12 -0
  37. package/src/nodes/layout.js +5 -1
  38. package/src/nodes/node.js +17 -3
  39. package/src/nodes/paint.js +117 -0
  40. package/src/nodes/scope.js +259 -0
  41. package/src/nodes/scrollable.js +53 -6
  42. package/src/nodes/text.js +2 -0
  43. package/src/nodes/textarea.js +1 -1
  44. package/src/nodes/textinput.js +1 -1
  45. package/src/nodes/window/anchoring.js +45 -18
  46. package/src/nodes/window/flush.js +6 -5
  47. package/src/nodes/window/popup.js +10 -0
  48. package/src/nodes/window/size.js +40 -2
  49. package/src/nodes/window/window.js +41 -14
  50. package/src/registry.js +2 -1
  51. package/src/screens.js +159 -24
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +164 -17
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +21 -5
  58. package/src/types/capabilities.d.ts +13 -1
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/events.d.ts +5 -0
  62. package/src/types/filedialog.d.ts +3 -1
  63. package/src/types/style.d.ts +57 -0
  64. package/src/types/system.d.ts +104 -0
  65. package/src/types/tray.d.ts +14 -2
  66. package/src/wayland/xkb.js +170 -59
  67. package/src/windowid.js +62 -20
@@ -11,7 +11,6 @@ import { forgetTopLevel, hasDropProps } from '../../dnd.js';
11
11
  import { clearPendingFrame } from '../../frames.js';
12
12
  import { FramePacer } from '../../pacing.js';
13
13
  import { endWindowState } from '../../windowstate.js';
14
- import { anchorOffscreen } from '../../anchor.js';
15
14
  import { topLevelWindows } from '../../windowid.js';
16
15
  import { WindowAnimation } from '../animation.js';
17
16
  import { WindowCascade } from '../cascade.js';
@@ -153,6 +152,12 @@ export class WindowNode extends Scrollable(Node) {
153
152
  // one flag everything reads (`_mapNow`, painting, a11y, anchoring).
154
153
  this._reactHidden = false;
155
154
  this.hidden = Boolean(props.hidden);
155
+ // A `<ThemeProvider>` this window takes its palette from other than its
156
+ // parent — one written above it at the root, or one directly inside the
157
+ // window it is nested in, which handed it on — and a third writer of
158
+ // `hidden`, since React hides the provider's node rather than the window
159
+ // under it (nodes/scope.js). Null everywhere else.
160
+ this._scope = null;
156
161
  // whether this is the tree's own top-level window rather than a nested
157
162
  // one or a popup — decided by realize(), read when it maps
158
163
  this._topLevel = false;
@@ -360,6 +365,11 @@ export class WindowNode extends Scrollable(Node) {
360
365
  attributes.eventMask = (attributes.eventMask ?? 0) | WINDOW_EVENT_MASK;
361
366
  const wnd = this.app.createWindow(attributes);
362
367
  this.window = wnd;
368
+ // What the window actually took, which is not always what was asked
369
+ // for: Cocoa puts a window on the point grid. The record is what the
370
+ // resize echo is compared against, so it has to be the size the echo
371
+ // will carry (#586, `_snapSize`).
372
+ this._requestedSize = { width: wnd.width, height: wnd.height };
363
373
  // Now that the visual is known: settle the capabilities, re-resolve any
364
374
  // `@supports` block against them, and start following the compositor.
365
375
  // Before the first paint, and before children realize against it.
@@ -422,8 +432,7 @@ export class WindowNode extends Scrollable(Node) {
422
432
  // and vanish.
423
433
  if (this.props.anchor) {
424
434
  this._watchAnchor();
425
- const node = this._anchorTarget(this.props.anchor.to);
426
- this._anchorLost = !node || anchorOffscreen(node, this.props.anchor.at);
435
+ this._anchorLost = this._anchorGone();
427
436
  }
428
437
  // Queued rather than mapped, when there is a commit to queue behind:
429
438
  // React hides a subtree only once it has inserted it (beginWindowMaps).
@@ -747,7 +756,7 @@ export class WindowNode extends Scrollable(Node) {
747
756
  // The flag `realize()`'s map will read — set directly, since there is
748
757
  // nothing on screen yet for the notification half of `_applyHidden`
749
758
  // to be about.
750
- this.hidden = this._reactHidden || Boolean(newProps.hidden);
759
+ this.hidden = this._hiddenByReact() || Boolean(newProps.hidden);
751
760
  return;
752
761
  }
753
762
 
@@ -804,10 +813,10 @@ export class WindowNode extends Scrollable(Node) {
804
813
  const geo = scaleWindowGeometry(newProps, this.scale);
805
814
  if (sizeChanged) {
806
815
  this._userSized = false;
807
- this._requestedSize = {
816
+ this._requestedSize = this._snapSize({
808
817
  width: isAutoSize(geo.width) ? wnd.width : geo.width,
809
818
  height: isAutoSize(geo.height) ? wnd.height : geo.height,
810
- };
819
+ });
811
820
  }
812
821
  if (geometryChanged) {
813
822
  if (typeof wnd.setState === 'function') {
@@ -821,8 +830,15 @@ export class WindowNode extends Scrollable(Node) {
821
830
  height: isAutoSize(geo.height) ? undefined : geo.height,
822
831
  });
823
832
  } else {
824
- if (sizeChanged && !isAutoSize(geo.width) && !isAutoSize(geo.height)) {
825
- wnd.resize?.(geo.width, geo.height);
833
+ // A window with no `setState` — Cocoa's — is resized whole or not at
834
+ // all, so an axis handed back to `'auto'` goes as the size recorded
835
+ // for it above: the one the window has, which this change leaves
836
+ // alone. Skipping the call for one `'auto'` axis dropped the other
837
+ // axis's change on the floor: `_refit()` then found the record
838
+ // already matching and sent nothing either (#585). With both axes
839
+ // `'auto'` there is no size to send, and `_refit()` works one out.
840
+ if (sizeChanged && !(isAutoSize(geo.width) && isAutoSize(geo.height))) {
841
+ wnd.resize?.(this._requestedSize.width, this._requestedSize.height);
826
842
  }
827
843
  if (movedByProps) {
828
844
  wnd.move?.(geo.x, geo.y);
@@ -887,14 +903,25 @@ export class WindowNode extends Scrollable(Node) {
887
903
  }
888
904
 
889
905
  /**
890
- * Re-derive `this.hidden` from its two writers the reconciler's flag and
891
- * the `hidden` prop and make the window agree. Either saying "hidden"
892
- * wins, so a `<Suspense>` revealing its content does not map a window the
893
- * app is holding off screen, and clearing the prop does not map one React
894
- * still hides.
906
+ * Whether React is hiding this window: its own flag, or one on the theme
907
+ * scope it is written under a `<Suspense>` around a `<ThemeProvider>` at
908
+ * the root hides the provider's node, the topmost host instance there.
909
+ * Kept apart from the window's own flag so an inner boundary that still
910
+ * hides the window is not overruled when an outer one reveals the scope.
911
+ */
912
+ _hiddenByReact() {
913
+ return this._reactHidden || (this._scope?._hiddenByReact() ?? false);
914
+ }
915
+
916
+ /**
917
+ * Re-derive `this.hidden` from its writers — React, through
918
+ * `_hiddenByReact`, and the `hidden` prop — and make the window agree.
919
+ * Either saying "hidden" wins, so a `<Suspense>` revealing its content does
920
+ * not map a window the app is holding off screen, and clearing the prop
921
+ * does not map one React still hides.
895
922
  */
896
923
  _applyHidden() {
897
- const hidden = this._reactHidden || Boolean(this.props.hidden);
924
+ const hidden = this._hiddenByReact() || Boolean(this.props.hidden);
898
925
  if (hidden === this.hidden) return;
899
926
  this.hidden = hidden;
900
927
  // An unmapped window draws nothing, so a loop inside one is frames
package/src/registry.js CHANGED
@@ -28,6 +28,7 @@ import {
28
28
  DRAWN_KINDS,
29
29
  CUSTOM_SEMANTIC_NAMES,
30
30
  CUSTOM_SELF_DAMAGED,
31
+ THEME_SCOPE,
31
32
  } from './nodes/kinds.js';
32
33
  import { Node } from './nodes/node.js';
33
34
  import { markLayoutsHotReloadSession } from './layouts.js';
@@ -35,7 +36,7 @@ import { markLayoutsHotReloadSession } from './layouts.js';
35
36
  /** kind -> definition. Insertion-ordered, which is the order errors list. */
36
37
  const registry = new Map();
37
38
 
38
- const RESERVED = new Set(['textchunk', 'svgchild']);
39
+ const RESERVED = new Set(['textchunk', 'svgchild', THEME_SCOPE]);
39
40
 
40
41
  // The re-registration policy for hot reload (issue #318). Module-scope
41
42
  // registration is the pattern the docs recommend and tree-shaking forces on
package/src/screens.js CHANGED
@@ -119,6 +119,11 @@ class ScreenSession {
119
119
  this._desktopAtom = null;
120
120
  this._snapshot = null;
121
121
  this._listeners = new Set();
122
+ /** A backend that has to be *asked* for its layout rather than told —
123
+ * see `setScreenPolling`. `_revalidate` re-reads it now; `_watched` is
124
+ * told whether anything is subscribed. */
125
+ this._revalidate = null;
126
+ this._watched = null;
122
127
  /** Every `X.on('event')` handler installed here, so `stop()` can take
123
128
  * them off again rather than leaving one per root on a shared client. */
124
129
  this._handlers = [];
@@ -138,7 +143,11 @@ class ScreenSession {
138
143
  }
139
144
  }
140
145
  this._handlers.length = 0;
146
+ const watched = this._listeners.size > 0;
141
147
  this._listeners.clear();
148
+ if (watched) this._watch(false);
149
+ this._revalidate = null;
150
+ this._watched = null;
142
151
  }
143
152
 
144
153
  /** Install an X event handler that this session owns. */
@@ -183,32 +192,35 @@ class ScreenSession {
183
192
 
184
193
  subscribe(fn) {
185
194
  this._listeners.add(fn);
186
- return () => this._listeners.delete(fn);
195
+ if (this._listeners.size === 1) this._watch(true);
196
+ return () => {
197
+ if (!this._listeners.delete(fn)) return;
198
+ if (!this._listeners.size) this._watch(false);
199
+ };
187
200
  }
188
- }
189
201
 
190
- /** The monitor a point is on, or the largest one when it is on none (a
191
- * window whose owner the WM has not placed yet, or coordinates from a
192
- * screen layout that has since changed). */
193
- function monitorAt(monitors, point) {
194
- if (!monitors?.length) return null;
195
- if (point) {
196
- for (const m of monitors) {
197
- if (
198
- point.x >= m.x &&
199
- point.x < m.x + m.width &&
200
- point.y >= m.y &&
201
- point.y < m.y + m.height
202
- ) {
203
- return m;
204
- }
202
+ /** Ask a pulled backend to re-read the layout, now. Synchronous: the
203
+ * callers are placement paths with no round trip available to them. */
204
+ revalidate() {
205
+ if (!this._revalidate || this.stopped) return;
206
+ try {
207
+ this._revalidate();
208
+ } catch {
209
+ // a backend that cannot answer leaves the layout it published
210
+ // standing, which is a better answer than none
205
211
  }
206
212
  }
207
- let best = monitors[0];
208
- for (const m of monitors) {
209
- if (m.width * m.height > best.width * best.height) best = m;
213
+
214
+ /** Whether anything is subscribed, for a backend that only has to keep
215
+ * asking while someone is listening. */
216
+ _watch(on) {
217
+ if (!this._watched) return;
218
+ try {
219
+ this._watched(on);
220
+ } catch {
221
+ // as above: its clock, its problem
222
+ }
210
223
  }
211
- return best;
212
224
  }
213
225
 
214
226
  /** The overlap of two rects, or `null` where they do not touch. */
@@ -221,6 +233,82 @@ function intersect(a, b) {
221
233
  return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
222
234
  }
223
235
 
236
+ /** How far apart two rects are, squared: zero where they meet, and the gap
237
+ * between their nearest edges otherwise. Squared because nothing compares
238
+ * it against a length — only against another of these. */
239
+ function gapSquared(a, b) {
240
+ const dx = Math.max(a.x - (b.x + b.width), b.x - (a.x + a.width), 0);
241
+ const dy = Math.max(a.y - (b.y + b.height), b.y - (a.y + a.height), 0);
242
+ return dx * dx + dy * dy;
243
+ }
244
+
245
+ /** The biggest monitor there is — the stand-in for "the one you look at",
246
+ * for a question with no position in it at all. */
247
+ function largestMonitor(monitors) {
248
+ let best = monitors[0];
249
+ for (const m of monitors) {
250
+ if (m.width * m.height > best.width * best.height) best = m;
251
+ }
252
+ return best;
253
+ }
254
+
255
+ /**
256
+ * The monitor `near` is on. `near` is a rect in screen coordinates, and a
257
+ * point is the 1x1 rect at it — the same containment a point used to get,
258
+ * since a 1x1 rect overlaps exactly the monitor that contains its corner.
259
+ *
260
+ * **The one it overlaps most**, because one corner of a rect does not say
261
+ * which monitor the rect is on. A menu-bar item's frame starts a few points
262
+ * *above* the top of its own display, and on a desk where another display
263
+ * reaches down past that edge, the corner alone is inside the *other*
264
+ * monitor — or inside none — and the popup opens there (#618). Every rect
265
+ * that has a size knows better than its corner does.
266
+ *
267
+ * **The nearest one**, by the gap between the rects, when it overlaps none.
268
+ * A rect that is off every monitor is nearly always just outside one of
269
+ * them — that same menu-bar furniture, a pointer at the very edge, a window
270
+ * the WM has not placed yet — and the nearest monitor is the only answer
271
+ * that has anything to do with where it was. The largest was the old answer
272
+ * and it can be anywhere on the desk.
273
+ *
274
+ * With no position at all (`near` null — an auto-sized window with no owner
275
+ * to open beside), the largest monitor, which is all there is to go on.
276
+ */
277
+ function monitorAt(monitors, near) {
278
+ if (!monitors?.length) return null;
279
+ if (!near) return largestMonitor(monitors);
280
+ // A degenerate rect counts as its own thinnest real version, the way
281
+ // `anchorOffscreen` reads a caret: a point is 1x1, and so is a rect whose
282
+ // size nobody filled in.
283
+ const rect = {
284
+ x: near.x,
285
+ y: near.y,
286
+ width: near.width > 1 ? near.width : 1,
287
+ height: near.height > 1 ? near.height : 1,
288
+ };
289
+ let best = null;
290
+ let most = 0;
291
+ for (const m of monitors) {
292
+ const over = intersect(m, rect);
293
+ const area = over ? over.width * over.height : 0;
294
+ if (area > most) {
295
+ best = m;
296
+ most = area;
297
+ }
298
+ }
299
+ if (best) return best;
300
+ let nearest = monitors[0];
301
+ let least = Infinity;
302
+ for (const m of monitors) {
303
+ const gap = gapSquared(m, rect);
304
+ if (gap < least) {
305
+ nearest = m;
306
+ least = gap;
307
+ }
308
+ }
309
+ return nearest;
310
+ }
311
+
224
312
  /**
225
313
  * The usable part of one monitor.
226
314
  *
@@ -262,12 +350,19 @@ function usable(monitor, work) {
262
350
 
263
351
  /**
264
352
  * The rect an auto-sized window may grow into, or `null` where there is
265
- * nothing to ask. `near` is a screen-coordinate point the window will open
266
- * next to — a `transientFor` owner's origin, in practice and picks the
267
- * monitor when there are several.
353
+ * nothing to ask. `near` is a screen-coordinate **rect** the window will
354
+ * open against — a `transientFor` owner's origin, the node a popup hangs
355
+ * off, the tray item a click reported — and picks the monitor when there
356
+ * are several (`monitorAt`); `{x, y}` alone is a point.
268
357
  */
269
358
  export function availableArea(app, near = null) {
270
359
  const session = sessions.get(app);
360
+ // The monitor a popup is flipped and clamped into is picked here, so a
361
+ // backend whose layout is pulled rather than pushed is asked *now*
362
+ // rather than answered from whatever it last read (`setScreenPolling`).
363
+ // A rect the desk has since moved lands inside another monitor's stale
364
+ // one, and the popup opens at that monitor's edge (#617).
365
+ session?.revalidate();
271
366
  const screen = session?.screenRect ?? null;
272
367
  if (!session) return screen;
273
368
  const monitor = monitorAt(session.monitors, near) ?? screen;
@@ -364,6 +459,46 @@ export function watchScreens(app, fn) {
364
459
  return session.subscribe(fn);
365
460
  }
366
461
 
462
+ /**
463
+ * Register a backend whose layout has to be **pulled**.
464
+ *
465
+ * X11 and Wayland are told: RandR sends an event, a `wl_output` announces
466
+ * itself, and a `publish` lands from the handler. The cocoa bridge keeps
467
+ * its `NSScreen` copy current on macOS's own
468
+ * `NSApplicationDidChangeScreenParametersNotification` but emits no event
469
+ * for it, so there a display plugged in, rearranged or made primary is a
470
+ * question nobody asked (#617). This is where the asking is wired up:
471
+ *
472
+ * - `revalidate()` re-reads the layout and publishes any change. Called
473
+ * before `availableArea()` picks the monitor a window is sized against
474
+ * or a popup is clamped into, which is where a stale rect does visible
475
+ * damage, and synchronous for that reason.
476
+ * - `watched(on)` is told when the *first* subscriber arrives and when the
477
+ * last one leaves. A change nobody asked about still has to reach
478
+ * `useScreens()`, which needs a clock where there is no event — and a
479
+ * clock that only runs while a component is watching costs an app that
480
+ * never asks nothing at all.
481
+ *
482
+ * Both are optional, and a session with neither behaves exactly as it did:
483
+ * this adds no work to the X11 path.
484
+ */
485
+ export function setScreenPolling(
486
+ app,
487
+ { revalidate = null, watched = null } = {},
488
+ ) {
489
+ let session = sessions.get(app);
490
+ if (!session) {
491
+ session = new ScreenSession(app);
492
+ sessions.set(app, session);
493
+ }
494
+ session._revalidate = revalidate;
495
+ session._watched = watched;
496
+ // Registered after a `useScreens()` already mounted — the backend still
497
+ // has to hear that it is being watched.
498
+ if (watched && session._listeners.size) session._watch(true);
499
+ return session;
500
+ }
501
+
367
502
  // --------------------------------------------------------------------------
368
503
  // Starting up
369
504
  // --------------------------------------------------------------------------