react-x11 2.16.0 → 2.17.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 (62) hide show
  1. package/README.md +38 -23
  2. package/package.json +3 -1
  3. package/src/Reconciler.js +82 -23
  4. package/src/a11y.js +18 -1
  5. package/src/acceleratorhooks.js +40 -6
  6. package/src/anchor.js +20 -2
  7. package/src/appcontext.js +8 -0
  8. package/src/appearance.js +36 -0
  9. package/src/{cocoa → backend}/context2d.js +27 -7
  10. package/src/capabilities.js +99 -1
  11. package/src/cocoa/app.js +204 -6
  12. package/src/cocoa/fonts.js +1 -1
  13. package/src/cocoa/overlay.js +2 -2
  14. package/src/cocoa/panewindow.js +2 -2
  15. package/src/cocoa/presenter.js +2 -2
  16. package/src/cocoa/surface.js +3 -3
  17. package/src/cocoa/window.js +2 -2
  18. package/src/events.js +21 -0
  19. package/src/foreignnodes.js +8 -3
  20. package/src/frame/index.js +30 -4
  21. package/src/glnodes.js +12 -1
  22. package/src/idle.js +59 -1
  23. package/src/index.d.ts +51 -1
  24. package/src/index.js +30 -3
  25. package/src/keysymchars.js +47 -0
  26. package/src/keysyms.d.ts +19 -1
  27. package/src/keysyms.js +107 -8
  28. package/src/launcher.js +17 -8
  29. package/src/launcherhooks.js +24 -10
  30. package/src/node.d.ts +1 -1
  31. package/src/nodes/cascade.js +9 -0
  32. package/src/nodes/node.js +6 -1
  33. package/src/nodes/window/hints.js +21 -2
  34. package/src/nodes/window/window.js +2 -2
  35. package/src/notifications.js +39 -14
  36. package/src/screens.js +159 -24
  37. package/src/taskbarhooks.js +164 -0
  38. package/src/transfer.js +20 -1
  39. package/src/trayhooks.js +1 -1
  40. package/src/types/capabilities.d.ts +32 -3
  41. package/src/types/elements.d.ts +23 -1
  42. package/src/types/events.d.ts +21 -0
  43. package/src/types/filedialog.d.ts +3 -1
  44. package/src/types/launcher.d.ts +20 -6
  45. package/src/types/taskbar.d.ts +79 -0
  46. package/src/wayland/context2d.js +1 -1
  47. package/src/wayland/xkb.js +170 -59
  48. package/src/win32/a11y.js +604 -0
  49. package/src/win32/app.js +768 -0
  50. package/src/win32/bezels.js +158 -0
  51. package/src/win32/dnd.js +283 -0
  52. package/src/win32/fonts.js +497 -0
  53. package/src/win32/glarea.js +548 -0
  54. package/src/win32/ime.js +267 -0
  55. package/src/win32/keymap.js +116 -0
  56. package/src/win32/native.js +54 -0
  57. package/src/win32/panehost.js +106 -0
  58. package/src/win32/panewindow.js +343 -0
  59. package/src/win32/shell.js +426 -0
  60. package/src/win32/surface.js +192 -0
  61. package/src/win32/window.js +659 -0
  62. package/src/windowid.js +128 -20
package/README.md CHANGED
@@ -18,9 +18,10 @@ Build GUI programs for a Linux desktop, for a display at the other end of an
18
18
  ssh connection, or as a native Mac app, with your React / React Native
19
19
  experience — flexbox layout, components, hooks, synthetic events.
20
20
 
21
- ### Two backends, one tree
21
+ ### Four backends, one tree
22
22
 
23
- The same components, the same hooks and the same `style` objects run on both:
23
+ The same components, the same hooks and the same `style` objects run on all
24
+ of them. The two that are mature:
24
25
 
25
26
  - **X11** ([the flagship](docs/remote.md)) — a Linux desktop, a display
26
27
  forwarded over `ssh -X`, `Xvfb` in CI, a thin client, or macOS through
@@ -35,20 +36,25 @@ The same components, the same hooks and the same `style` objects run on both:
35
36
  thin mechanism-only Objective-C++ bridge — an optional dependency shipping
36
37
  prebuilds, absent on Linux installs.
37
38
 
38
- `createRoot()` picks for you: **Cocoa on macOS** when the bridge is
39
- installed, X11 via `$DISPLAY` everywhere else, and X11 on a Mac without the
40
- bridge so an XQuartz setup keeps working. `createRoot({ backend: 'x11' })`
41
- or `REACT_X11_BACKEND=x11` pins it. On macOS the app's JS runs on a worker
39
+ `createRoot()` picks for you: **Cocoa on macOS** and **win32 on Windows**
40
+ when the matching bridge is installed, X11 via `$DISPLAY` everywhere else
41
+ and X11 on either of those without its bridge, so an XQuartz or WSLg setup
42
+ keeps working. `createRoot({ backend: 'x11' })` or `REACT_X11_BACKEND=x11`
43
+ pins it. On macOS the app's JS runs on a worker
42
44
  while AppKit keeps the main thread, so a menu, a drag or a live resize never
43
45
  stops it — `node app.js` is enough, and `REACT_X11_THREADED=0` keeps the
44
46
  main thread ([docs/macos.md](docs/macos.md#js-on-a-worker-a-ui-thread-of-the-bridges-own)).
45
47
 
46
- Two more backends are on the way, and the goal they serve is **full
47
- cross-platform support**: **Windows** Win32 windows, Direct2D and
48
- DirectWrite, DWM compositing — with [docs/windows.md](docs/windows.md) as
49
- its PRD, and **native Wayland**, with [docs/wayland.md](docs/wayland.md)
50
- as its RFC. Both arrive as backends beside these two, not replacements for
51
- either: the same tree, the same components, the same `style` objects.
48
+ Two more backends exist beside these, serving the goal of **full
49
+ cross-platform support**, and both are younger than the two above.
50
+ **native Wayland** ([docs/wayland.md](docs/wayland.md)) is opt-in rather than
51
+ automatic, because X11 stays the default on Linux — the remote case is the
52
+ flagship reason this project exists and Wayland has no network transparency.
53
+ **Windows** ([docs/windows.md](docs/windows.md)) Win32 windows, Direct2D
54
+ and DirectWrite, DWM compositing through DirectComposition — renders and
55
+ takes mouse input, with the desktop integrations still to come. Each is a
56
+ backend beside the others rather than a replacement for any of them: the
57
+ same tree, the same components, the same `style` objects.
52
58
 
53
59
  Layout is [yoga-layout](https://www.npmjs.com/package/yoga-layout) (WASM) on
54
60
  both, and text shaping is [fontkit](https://github.com/foliojs/fontkit) on
@@ -125,17 +131,26 @@ That is the shape of the problem this is good at:
125
131
  And the shape it is not good at, so you can stop here rather than in week
126
132
  three:
127
133
 
128
- - **Windows — today.** There is no Windows backend yet, so an app that has
129
- to ship on Windows now wants Electron or Tauri. One is coming: a native
130
- backend over Win32 windows, Direct2D and DirectWrite, composited by DWM
131
- through DirectComposition, on a mechanism-only bridge shaped like the
132
- Cocoa one. [docs/windows.md](docs/windows.md) is the PRD, from the
133
- threading model up to what each desktop integration becomes. Two targets
134
- ship meanwhile X11 and Cocoa and they are not the same app: the
135
- desktop-shell half of X11 (`<foreign>` embedding, panel struts,
136
- substructure redirect, the window-manager example below) has no macOS
137
- equivalent, and `react-x11/test` drives the X11 backend only.
138
- [docs/macos.md](docs/macos.md) says which is which.
134
+ - **Windows — today.** The Windows backend is young, and further along than
135
+ that sounds. Win32 windows composited by DWM through DirectComposition,
136
+ Direct2D and DirectWrite behind the same contracts the Cocoa backend
137
+ proved, OpenGL, the keyboard and all five mouse buttons, popups, the
138
+ clipboard, file dialogs, the tray, the taskbar, global hotkeys,
139
+ notifications and window states on a mechanism-only bridge shaped like
140
+ the Cocoa one, with a test suite and CI of its own. The examples run.
141
+ Drag and drop works both ways with the desktop, and the taskbar's own
142
+ surfaces — a thumbnail toolbar, a jump list, recent documents are there
143
+ behind a capability, so an app that also runs elsewhere asks
144
+ `useSupports()` rather than the platform. What it does not have is **IME
145
+ and screen-reader support**: an app is unusable with a screen reader and
146
+ in CJK input. So an app that needs either on Windows _this month_ still
147
+ wants Electron or Tauri.
148
+ [docs/windows-integrations.md](docs/windows-integrations.md) is the
149
+ measured status and [docs/windows.md](docs/windows.md) the design. The three
150
+ targets are not the same app: the desktop-shell half of X11 (`<foreign>`
151
+ embedding, panel struts, substructure redirect, the window-manager example
152
+ below) has no macOS or Windows equivalent, and `react-x11/test` drives the
153
+ X11 backend only. [docs/macos.md](docs/macos.md) says which is which.
139
154
  - **native Wayland — today.** There is no Wayland backend yet. Ordinary
140
155
  application windows work fine on a Wayland desktop through Xwayland, which
141
156
  is not going away — but the desktop-shell half of X11 (panel struts,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -33,6 +33,7 @@
33
33
  "examples:attention": "tsx examples/attention.jsx",
34
34
  "examples:badge": "tsx examples/badge.jsx",
35
35
  "examples:tray": "tsx examples/tray.jsx",
36
+ "examples:taskbar": "tsx examples/taskbar.jsx",
36
37
  "examples:desktop": "tsx examples/desktop.jsx",
37
38
  "examples:tooltips": "tsx examples/tooltips.jsx",
38
39
  "examples:chat": "tsx examples/chat.jsx",
@@ -109,6 +110,7 @@
109
110
  "optionalDependencies": {
110
111
  "@windowkit/appkit": "^0.12.0",
111
112
  "@windowkit/wayland": "^3.1.1",
113
+ "@windowkit/win32": "^0.0.1",
112
114
  "dbus-native": "^0.15.1",
113
115
  "x11-dri": "^0.9.0"
114
116
  },
package/src/Reconciler.js CHANGED
@@ -116,6 +116,9 @@ const ROOT_CONTEXT = Object.freeze({
116
116
  isInsideText: false,
117
117
  isInsideSvg: false,
118
118
  atRoot: true,
119
+ // Nothing is above the root, so a window written straight into it waits for
120
+ // no palette — unlike one under a `<ThemeProvider>` there.
121
+ underRootScope: false,
119
122
  inWindow: false,
120
123
  });
121
124
 
@@ -191,6 +194,11 @@ const HostConfig = {
191
194
  // Still at the root under a `<ThemeProvider>` written there, which
192
195
  // draws nothing; under anything else, inside a window.
193
196
  atRoot: parentHostContext.atRoot && type === THEME_SCOPE,
197
+ // *Below* one of those providers, which is a different fact: a window
198
+ // here is handed its palette when the scope inserts it, after it was
199
+ // built. `atRoot` is true at the root itself, where no provider is
200
+ // waiting to hand anything over, so the two cannot be one flag.
201
+ underRootScope: parentHostContext.atRoot && type === THEME_SCOPE,
194
202
  // Directly inside a window, where a provider can hand a nested window
195
203
  // on to it.
196
204
  inWindow: type === 'window' || type === 'popup',
@@ -255,10 +263,19 @@ const HostConfig = {
255
263
  case 'window':
256
264
  // No X11 calls here: the render phase may be discarded. The real
257
265
  // window is created top-down in the commit phase (realize).
266
+ // `underRootScope` goes in at construction, not after it: this
267
+ // constructor resolves the window's own style, and a window under a
268
+ // root `<ThemeProvider>` is handed that palette only when the scope
269
+ // inserts it (nodes/scope.js). Until then its ancestry is incomplete
270
+ // and every `$token` the provider defines would be reported as
271
+ // unknown — a warning for a style that resolves correctly a moment
272
+ // later, and under `REACT_X11_STRICT_TOKENS=1` a throw that killed an
273
+ // app whose palette was fine.
258
274
  node = new WindowNode(
259
275
  rootContainer,
260
276
  windowAttributes(props, scaleOf(rootContainer)),
261
277
  props,
278
+ { awaitsRootScope: hostContext.underRootScope },
262
279
  );
263
280
  break;
264
281
  case 'popup':
@@ -641,11 +658,18 @@ const isNtkApp = (v) =>
641
658
  */
642
659
  function resolveBackend(options) {
643
660
  const asked = options.backend ?? process.env.REACT_X11_BACKEND ?? 'auto';
644
- if (asked === 'x11' || asked === 'cocoa' || asked === 'wayland') return asked;
661
+ if (
662
+ asked === 'x11' ||
663
+ asked === 'cocoa' ||
664
+ asked === 'wayland' ||
665
+ asked === 'win32'
666
+ ) {
667
+ return asked;
668
+ }
645
669
  if (asked !== 'auto') {
646
670
  throw new Error(
647
671
  `react-x11: unknown backend ${JSON.stringify(asked)} — expected ` +
648
- "'x11', 'cocoa', 'wayland' or 'auto'.",
672
+ "'x11', 'cocoa', 'wayland', 'win32' or 'auto'.",
649
673
  );
650
674
  }
651
675
  // Naming an X endpoint is choosing X11: a `display` or a `stream` (the
@@ -655,7 +679,13 @@ function resolveBackend(options) {
655
679
  if (options.display !== undefined || options.stream !== undefined) {
656
680
  return 'x11';
657
681
  }
658
- return process.platform === 'darwin' ? 'cocoa' : 'x11';
682
+ if (process.platform === 'darwin') return 'cocoa';
683
+ // Windows has no X server to fall back to in the ordinary case, so 'auto'
684
+ // reaches for the native backend the way it does on a mac — and falls back
685
+ // to X11 below if the bridge is not installed, which is what keeps a Cygwin
686
+ // or WSLg setup with DISPLAY set working.
687
+ if (process.platform === 'win32') return 'win32';
688
+ return 'x11';
659
689
  }
660
690
 
661
691
  // What a root that opens its own connection forwards to ntk. `stream` is
@@ -769,17 +799,27 @@ export async function createRoot(options = {}) {
769
799
  // synchronous block, and only where the handshake takes longer than it. It
770
800
  // is not measurable on a Unix socket, and it is lost in the noise on a link
771
801
  // slow enough to matter. This is the right order, not a fast one.
772
- const connectX11 = () =>
773
- connect(
774
- Object.fromEntries(
775
- CONNECT_OPTIONS.filter((k) => rest[k] !== undefined).map((k) => [
776
- k,
777
- rest[k],
778
- ]),
779
- ),
802
+ // `REACT_X11_FONT_SOURCE` is the companion to `REACT_X11_BACKEND` above:
803
+ // an A/B run without touching code. ntk finds its fonts through fontconfig,
804
+ // and a machine with no `fc-match` — a slim container, or Windows, where
805
+ // the X11 backend is only ever run to compare it against the native one —
806
+ // has to be told where the fonts are instead. An explicit `fontSource`
807
+ // wins; this only fills the gap.
808
+ const connectX11 = () => {
809
+ const options = Object.fromEntries(
810
+ CONNECT_OPTIONS.filter((k) => rest[k] !== undefined).map((k) => [
811
+ k,
812
+ rest[k],
813
+ ]),
780
814
  );
815
+ options.fontSource ??= process.env.REACT_X11_FONT_SOURCE || undefined;
816
+ if (options.fontSource === undefined) delete options.fontSource;
817
+ return connect(options);
818
+ };
781
819
  const cocoaAsked =
782
820
  rest.backend === 'cocoa' || process.env.REACT_X11_BACKEND === 'cocoa';
821
+ const win32Asked =
822
+ rest.backend === 'win32' || process.env.REACT_X11_BACKEND === 'win32';
783
823
  const connecting = !owned
784
824
  ? Promise.resolve(borrowed)
785
825
  : backend === 'wayland'
@@ -791,25 +831,44 @@ export async function createRoot(options = {}) {
791
831
  import('./wayland/app.js').then(({ createWaylandApp }) =>
792
832
  createWaylandApp(rest),
793
833
  )
794
- : backend === 'cocoa'
795
- ? import('./cocoa/app.js')
796
- .then(({ createCocoaApp }) => createCocoaApp(rest))
834
+ : backend === 'win32'
835
+ ? import('./win32/app.js')
836
+ .then(({ createWin32App }) => createWin32App(rest))
797
837
  .catch((err) => {
798
- // Asked for by name, the bridge is required and its absence is
799
- // the error (it says how to install). Reached by 'auto', a mac
800
- // without it falls back to X11 so an XQuartz setup keeps
801
- // working — said once, because a silent fallback would look
802
- // like the native backend being broken rather than absent.
803
- if (cocoaAsked) throw err;
838
+ // Same bargain as cocoa's below: asked for by name the bridge is
839
+ // required and its absence is the error, reached by 'auto' a
840
+ // machine without it falls back to X11 so a WSLg or Cygwin setup
841
+ // with DISPLAY set keeps working — said once, because a silent
842
+ // fallback reads as the native backend being broken rather than
843
+ // absent.
844
+ if (win32Asked) throw err;
804
845
  if (process.env.NODE_ENV !== 'production') {
805
846
  console.warn(
806
- 'react-x11: no @windowkit/appkit bridge — falling back to the ' +
847
+ 'react-x11: no @windowkit/win32 bridge — falling back to the ' +
807
848
  `X11 backend. (${err.message.split('\n')[0]})`,
808
849
  );
809
850
  }
810
851
  return connectX11();
811
852
  })
812
- : connectX11();
853
+ : backend === 'cocoa'
854
+ ? import('./cocoa/app.js')
855
+ .then(({ createCocoaApp }) => createCocoaApp(rest))
856
+ .catch((err) => {
857
+ // Asked for by name, the bridge is required and its absence is
858
+ // the error (it says how to install). Reached by 'auto', a mac
859
+ // without it falls back to X11 so an XQuartz setup keeps
860
+ // working — said once, because a silent fallback would look
861
+ // like the native backend being broken rather than absent.
862
+ if (cocoaAsked) throw err;
863
+ if (process.env.NODE_ENV !== 'production') {
864
+ console.warn(
865
+ 'react-x11: no @windowkit/appkit bridge — falling back to the ' +
866
+ `X11 backend. (${err.message.split('\n')[0]})`,
867
+ );
868
+ }
869
+ return connectX11();
870
+ })
871
+ : connectX11();
813
872
  const layout = loadLayout();
814
873
  const integrations = loadIntegrations(); // null when there is nothing to install
815
874
  const [app] = await Promise.all([connecting, layout, integrations]);
@@ -843,7 +902,7 @@ export async function createRoot(options = {}) {
843
902
  // critical path — every rung that fails is a normal, silent "off"
844
903
  // (docs/accessibility.md). Deliberately not awaited: a root must not
845
904
  // wait on a bus that is not there.
846
- startA11y();
905
+ startA11y(app);
847
906
 
848
907
  // Before anything renders: the launch id has to be on the first toplevel
849
908
  // before it maps, and the environment variable has to be consumed whether
package/src/a11y.js CHANGED
@@ -1275,12 +1275,29 @@ function a11yEnabled() {
1275
1275
  * it stopped — which is the debugging story for "why does Orca not see my
1276
1276
  * app".
1277
1277
  */
1278
- export function startA11y() {
1278
+ export function startA11y(app) {
1279
1279
  if (startPromise) return startPromise;
1280
1280
  if (!a11yEnabled()) {
1281
1281
  startPromise = Promise.resolve(null);
1282
1282
  return startPromise;
1283
1283
  }
1284
+ // A backend with an accessibility bridge of its own answers first, and its
1285
+ // presence *is* the capability — the same rule the rest of the library
1286
+ // uses. The Windows backend installs one, because UI Automation asks
1287
+ // synchronously and has to be answered from a pushed copy rather than from
1288
+ // the live tree (docs/windows.md §Accessibility); AT-SPI's asynchronous
1289
+ // questions are answered from the tree itself, below.
1290
+ if (typeof app?.startAccessibility === 'function') {
1291
+ startPromise = Promise.resolve()
1292
+ .then(() => app.startAccessibility())
1293
+ .catch((err) => {
1294
+ if (process.env.REACT_X11_A11Y) {
1295
+ console.warn('react-x11: accessibility bridge failed to start:', err);
1296
+ }
1297
+ return null;
1298
+ });
1299
+ return startPromise;
1300
+ }
1284
1301
  startPromise = import('./atspi.js')
1285
1302
  .then((atspi) => atspi.start())
1286
1303
  .catch((err) => {
@@ -11,6 +11,35 @@ import { useEffect, useMemo, useRef } from 'react';
11
11
  import { matchesShortcut } from './accelerators.js';
12
12
  import { useTopLevelWindow } from './windowid.js';
13
13
 
14
+ /**
15
+ * A binding whose anchor resolved to nothing binds nothing — there is no
16
+ * event manager to register it with, and the effect runs once, so it will
17
+ * not come back. Silent, that is a shortcut that simply never fires, with
18
+ * the tree, the chord and the handler all looking correct (issue #616).
19
+ *
20
+ * Once per process, in development: the mistake is structural, and an app
21
+ * that made it once made it for every chord it declares.
22
+ */
23
+ let warnedAboutAnchor = false;
24
+ export function resetAcceleratorWarningForTests() {
25
+ warnedAboutAnchor = false;
26
+ }
27
+
28
+ function warnUnanchored() {
29
+ if (process.env.NODE_ENV === 'production' || warnedAboutAnchor) return;
30
+ warnedAboutAnchor = true;
31
+ console.warn(
32
+ 'react-x11: a shortcut was bound in a component with no window to hang ' +
33
+ 'it off, so it is bound to nothing and will never fire. By default a ' +
34
+ "binding belongs to the tree's top-level <window> or, in an app with " +
35
+ 'none, the root-level <popup> holding the keyboard. Anchor it ' +
36
+ 'explicitly with `scope`:\n' +
37
+ ' const here = useRef(null);\n' +
38
+ " useAccelerator([['space']], onToggle, { scope: here });\n" +
39
+ ' return <box ref={here}>…</box>;',
40
+ );
41
+ }
42
+
14
43
  /**
15
44
  * Bind a chord for as long as this component is mounted, anchored at
16
45
  * `anchorRef` — the node the binding belongs to, which is what decides
@@ -30,7 +59,10 @@ export function useAcceleratorEntry(anchorRef, handle, enabled = true) {
30
59
  useEffect(() => {
31
60
  if (!enabled) return undefined;
32
61
  const manager = anchorRef.current?.root?.events;
33
- if (!manager) return undefined;
62
+ if (!manager) {
63
+ warnUnanchored();
64
+ return undefined;
65
+ }
34
66
  return manager.registerAccelerator({
35
67
  anchor: () => anchorRef.current ?? null,
36
68
  handle: (ev) => live.current?.(ev) ?? false,
@@ -52,11 +84,13 @@ export function useAcceleratorEntry(anchorRef, handle, enabled = true) {
52
84
  * The handler is called with the key event, and the key is consumed.
53
85
  *
54
86
  * By default the binding belongs to the window the component is in, which is
55
- * what an application-wide shortcut wants. Two options for when it is not:
56
- * `enabled: false` unbinds it without unmounting anything, and `scope` takes
57
- * a ref to a node the binding hangs off instead the way to give a modal
58
- * `<Dialog>` a shortcut of its own, since a binding on the window behind it
59
- * is one the modal has taken the keyboard from.
87
+ * what an application-wide shortcut wants or, in an app with no `<window>`
88
+ * at all, to the root-level `<popup>` that took the keyboard, which is the
89
+ * whole of a tray popover's UI (`useTopLevelWindow`). Two options for when
90
+ * that is not it: `enabled: false` unbinds it without unmounting anything,
91
+ * and `scope` takes a ref to a node the binding hangs off instead — the way
92
+ * to give a modal `<Dialog>` a shortcut of its own, since a binding on the
93
+ * window behind it is one the modal has taken the keyboard from.
60
94
  */
61
95
  export function useAccelerator(shortcut, handler, options = {}) {
62
96
  const { enabled = true, scope } = options;
package/src/anchor.js CHANGED
@@ -148,7 +148,20 @@ export function deviceAnchorArea(node) {
148
148
  if (!app) return null;
149
149
  const at = screenRect(node);
150
150
  const s = node.scale ?? 1;
151
- return availableArea(app, at ? { x: at.x * s, y: at.y * s } : null);
151
+ // The whole rect, not its corner: a window straddling two monitors has
152
+ // nodes on each, and the monitor a node is on is the one it covers most of
153
+ // (`monitorAt`, src/screens.js).
154
+ return availableArea(
155
+ app,
156
+ at
157
+ ? {
158
+ x: at.x * s,
159
+ y: at.y * s,
160
+ width: at.width * s,
161
+ height: at.height * s,
162
+ }
163
+ : null,
164
+ );
152
165
  }
153
166
 
154
167
  /**
@@ -248,7 +261,12 @@ export function anchorScreenRect(app, rect, options = {}) {
248
261
  width: (rect.width ?? 0) * s,
249
262
  height: (rect.height ?? 0) * s,
250
263
  };
251
- const area = app ? availableArea(app, { x: anchor.x, y: anchor.y }) : null;
264
+ // The monitor is picked from the whole rect rather than its top-left
265
+ // corner, because a tray item's frame is not inside its own display: a
266
+ // menu-bar button reports a rect that starts a few points above the top
267
+ // edge, and the corner alone lands on whichever display happens to reach
268
+ // up past it — a different one, on a desk with a taller head (#618).
269
+ const area = app ? availableArea(app, anchor) : null;
252
270
  return placeAgainst(anchor, anchor, area, s, options);
253
271
  }
254
272
 
package/src/appcontext.js CHANGED
@@ -80,6 +80,14 @@ const FEATURES = {
80
80
  },
81
81
  embedding: { watch: NEVER_CHANGES, read: canEmbed },
82
82
  glOverlay: { watch: NEVER_CHANGES, read: canOverlay },
83
+ // Everything here is a property of the **display**: what the server, the
84
+ // compositor and the drawing pipeline can do. A desktop's own surfaces --
85
+ // a tray, a launcher icon and what hangs off it, a notification daemon --
86
+ // are a different question with a different shape, because they can appear
87
+ // and vanish while the process runs and because "available" alone is a lie
88
+ // about them. They go through `desktopCapability()` (src/capabilities.js),
89
+ // which answers `{ available, backend, features }`. Adding one here would
90
+ // flatten that to a boolean and lose the mechanism with it.
83
91
  };
84
92
 
85
93
  /**
package/src/appearance.js CHANGED
@@ -955,6 +955,38 @@ async function macosRung() {
955
955
  return runWatcher();
956
956
  }
957
957
 
958
+ // --------------------------------------------------------------------------
959
+ // Rung 4: Windows
960
+ // --------------------------------------------------------------------------
961
+
962
+ /**
963
+ * The backend answers this one rather than this file reaching for a bridge:
964
+ * the values live behind a native addon that exists only on Windows, and
965
+ * asking the app for them is the capability test every other ladder here
966
+ * makes (AGENTS.md, "Find the mechanism, not the platform").
967
+ *
968
+ * All four values come from one source — the Personalize key for light or
969
+ * dark, DWM for the accent, and the two accessibility flags from
970
+ * `SystemParametersInfo` — so this rung owns every field, which is the rule
971
+ * the ladder is ordered for.
972
+ *
973
+ * Live rather than polled: Windows broadcasts `WM_SETTINGCHANGE`,
974
+ * `WM_THEMECHANGED` and `WM_DWMCOLORIZATIONCOLORCHANGED` to every top-level
975
+ * window, and the bridge's UI thread has one.
976
+ */
977
+ async function windowsRung(app) {
978
+ if (typeof app?.systemAppearance !== 'function') return false;
979
+ const values = app.systemAppearance();
980
+ if (!values) return false;
981
+ publish(values, 'windows');
982
+ app.onAppearanceChange?.(() => {
983
+ if (owner !== 'windows') return;
984
+ const next = app.systemAppearance();
985
+ if (next) publish(next, 'windows');
986
+ });
987
+ return true;
988
+ }
989
+
958
990
  // Killed rather than left behind: `unref()` keeps it from holding *this*
959
991
  // process open, and nothing keeps it from outliving it.
960
992
  process.on('exit', () => {
@@ -975,6 +1007,10 @@ async function runLadder(app) {
975
1007
  // an XQuartz display never reaches this at all, which is correct — it
976
1008
  // cannot read that Mac's defaults.
977
1009
  ['macos', macosRung],
1010
+ // Before XSETTINGS for the same reason macOS is: where the process is
1011
+ // Windows, the desktop's own preference is the one the user set, and an
1012
+ // XSETTINGS daemon there would be something they installed by hand.
1013
+ ['windows', () => windowsRung(app)],
978
1014
  ['xsettings', () => xsettingsRung(app)],
979
1015
  ]) {
980
1016
  let answered = false;
@@ -1,9 +1,23 @@
1
- // A canvas-shaped 2d context over a @windowkit/appkit CoreGraphics surface.
1
+ // A canvas-shaped 2d context over a **verb table** — the drawing dialect
2
+ // every native backend answers, and the one place it is written down.
2
3
  //
3
- // This is the raster half of the Cocoa backend: on the surface presenter it
4
- // is the whole drawing path, and on the layer presenter it stays as the
5
- // fallback every painted-code node (<canvas>, <svg>, registered elements)
6
- // rasters through docs/macos.md §"Custom drawing on a layer tree".
4
+ // This is macos.md §"The split" step 4, taken when a second native backend
5
+ // started: the class takes its `native` as a constructor argument and calls
6
+ // nothing else, so one wrapper drives @windowkit/appkit's CoreGraphics verbs
7
+ // and @windowkit/win32's Direct2D verbs alike. A bridge that lacks an
8
+ // optional verb is feature-detected (`ctxSetBlendMode`, `blitSurface`,
9
+ // `ctxDrawSymbol`) and degrades rather than throwing.
10
+ //
11
+ // **Wayland is deliberately not here.** src/wayland/context2d.js is not
12
+ // another copy of this file — it is a GLES rasterizer, wayland.md's Tier D,
13
+ // which implements the same dialect by drawing it rather than by forwarding
14
+ // it. The two are different layers, not duplicates, and folding one into the
15
+ // other would lose that.
16
+ //
17
+ // On the surface presenter this is the whole drawing path; on the layer
18
+ // presenter it stays as the fallback every painted-code node (<canvas>,
19
+ // <svg>, registered elements) rasters through — docs/macos.md §"Custom
20
+ // drawing on a layer tree".
7
21
  //
8
22
  // The native surface holds the real graphics state (paths, CTM, clip); this
9
23
  // class keeps the JS-visible state (fillStyle strings, gradient objects,
@@ -265,7 +279,7 @@ class SolidPicture {
265
279
  }
266
280
  }
267
281
 
268
- export class CocoaContext2D {
282
+ export class BackendContext2D {
269
283
  /**
270
284
  * @param native the @windowkit/appkit module
271
285
  * @param surfaceOf () => current surface handle — the owner replaces the
@@ -1425,9 +1439,15 @@ export class CocoaContext2D {
1425
1439
  const family = m ? m[4] : 'sans-serif';
1426
1440
  const weight = m?.[2] === 'bold' ? 700 : m?.[2] ? Number(m[2]) : 400;
1427
1441
  const style = m?.[1] ? 'italic' : 'normal';
1442
+ // No colour unless the caller named one, which is what `_contextInk`
1443
+ // above is waiting for: a layout with no ink of its own is drawn with the
1444
+ // context's fill, the way `fillText` is defined to be. Defaulting the
1445
+ // base to black instead made every layout carry an ink, so `fillStyle`
1446
+ // was read, found to be irrelevant, and never applied — a `fillText`
1447
+ // under a white fill came out black on both backends.
1428
1448
  return fonts.layout(
1429
1449
  [{ text: String(text), family, size, weight, style, color }],
1430
- { family, size, weight, style, color: color ?? '#000' },
1450
+ { family, size, weight, style, color },
1431
1451
  {},
1432
1452
  );
1433
1453
  }