react-x11 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/events.js CHANGED
@@ -733,7 +733,16 @@ export class EventManager {
733
733
  * way somewhere rather than one, and React must be free to interrupt the
734
734
  * render it started for the notch before.
735
735
  */
736
- _onWheel(native) {
736
+ /**
737
+ * @param {object} native ntk's wheel event, in this window's coordinates
738
+ * @param {object} [over] the node the wheel happened over, when the caller
739
+ * already knows. A `<glarea>` owns its own X window, so the server
740
+ * delivers the event *there* and the surface hands it back translated
741
+ * (src/glnodes.js) — and a hit test would answer with the box behind it,
742
+ * because a window-owning child is not in its parent's paint order.
743
+ * Knowing beats guessing; everything after this line is the same.
744
+ */
745
+ _onWheel(native, over = null) {
737
746
  // The first wheel is what says this window wants smooth scrolling. It was
738
747
  // created on core events — an XI2 selection costs four times as many
739
748
  // bytes per *pointer move*, which a window that is never scrolled would
@@ -747,7 +756,7 @@ export class EventManager {
747
756
  // outside gets, and for the same reason (`_pressOutside`).
748
757
  if (this._dismissOutside(native)) return;
749
758
  runWithPriority(ContinuousEventPriority, () => {
750
- const target = this._hit(native);
759
+ const target = over ?? this._hit(native);
751
760
  // Shift turns a vertical wheel sideways — the convention for the mouse
752
761
  // and the touchpad that have no horizontal axis. Read off the delta
753
762
  // rather than off the source: a plain wheel mouse on an XI2 connection
@@ -233,6 +233,15 @@ export async function runFrameChild(transport, options = {}) {
233
233
  return fatal('connect', err);
234
234
  }
235
235
 
236
+ // A backend that presents panes over shared memory instead of a server
237
+ // (the Cocoa one) takes the channel itself: geometry and input in,
238
+ // presents out. Feature-detected — the X11 pane path has a real window
239
+ // and a real server and needs none of this.
240
+ root.app.attachPaneChannel?.({
241
+ send: (msg) => transport.send(msg),
242
+ onMessage: (cb) => transport.onMessage(cb),
243
+ });
244
+
236
245
  let closing = false;
237
246
  const close = async () => {
238
247
  if (closing) return;
@@ -43,6 +43,7 @@ import React, {
43
43
  useState,
44
44
  } from 'react';
45
45
 
46
+ import { useAppOrNull } from '../appcontext.js';
46
47
  import { FrameEnv } from './env.js';
47
48
  import { CallbackTable, PROTOCOL } from './protocol.js';
48
49
 
@@ -220,6 +221,11 @@ export function Frame({
220
221
  ref,
221
222
  }) {
222
223
  const env = useContext(FrameEnv);
224
+ // A backend that composites panes from shared memory (Cocoa) declares
225
+ // itself with createPaneHost; everything else embeds the pane's real
226
+ // window through <foreign>, exactly as before.
227
+ const appOrNull = useAppOrNull();
228
+ const paneApp = appOrNull?.createPaneHost ? appOrNull : null;
223
229
  const [state, setState] = useState({
224
230
  phase: 'starting',
225
231
  windowId: null,
@@ -365,9 +371,10 @@ export function Frame({
365
371
  // rather than one frame of default before an update lands.
366
372
  const { props: p, env: e, bridge: b } = current.current;
367
373
  const node = containerRef.current;
374
+ const sc = node?.scale ?? 1;
368
375
  const rect = {
369
- width: Math.max(1, Math.round(node?.abs?.width || 0)) || 400,
370
- height: Math.max(1, Math.round(node?.abs?.height || 0)) || 300,
376
+ width: Math.round((node?.abs?.width || 0) / sc) || 400,
377
+ height: Math.round((node?.abs?.height || 0) / sc) || 300,
371
378
  };
372
379
  s.sent = { props: p, env: e, bridge: b };
373
380
  trySend({
@@ -413,6 +420,23 @@ export function Frame({
413
420
  }, [props, env, bridge, generation]);
414
421
 
415
422
  if (state.phase === 'running') {
423
+ if (paneApp) {
424
+ return h(PaneHostView, {
425
+ containerRef,
426
+ session,
427
+ app: paneApp,
428
+ style,
429
+ focusable,
430
+ onEmbedError: (err) => {
431
+ session.current?.shutdown?.();
432
+ setState({
433
+ phase: 'failed',
434
+ windowId: null,
435
+ error: Object.assign(err, { phase: 'embed' }),
436
+ });
437
+ },
438
+ });
439
+ }
416
440
  return h('foreign', {
417
441
  ref: containerRef,
418
442
  windowId: state.windowId,
@@ -442,3 +466,139 @@ export function Frame({
442
466
  : null,
443
467
  );
444
468
  }
469
+
470
+ /**
471
+ * The Cocoa pane region: a box for layout, focus and input — hit-tested
472
+ * here, in the host, and forwarded over the channel — with a backend pane
473
+ * host object carrying the composited layer. The pane process presents by
474
+ * message; this view points the layer at each presented buffer.
475
+ */
476
+ function PaneHostView({
477
+ containerRef,
478
+ session,
479
+ app,
480
+ style,
481
+ focusable,
482
+ onEmbedError,
483
+ }) {
484
+ // `onEmbedError` is a fresh closure every host render (it captures
485
+ // setState), and the host ticks its own state many times a second — so it
486
+ // must not be an effect dependency, or the effect below tears the pane
487
+ // layer down and rebuilds it on every host render, and the gap between
488
+ // `host.destroy()` and the next present is a visible full-pane flash. A
489
+ // ref carries the latest callback into a once-per-session effect.
490
+ const embedErrorRef = useRef(onEmbedError);
491
+ embedErrorRef.current = onEmbedError;
492
+
493
+ useEffect(() => {
494
+ const s = session.current;
495
+ const node = containerRef.current;
496
+ const wnd = node?.root?.window;
497
+ if (!s || !node || !wnd) {
498
+ embedErrorRef.current?.(
499
+ new Error('the pane region has no window to sit in'),
500
+ );
501
+ return undefined;
502
+ }
503
+ let host;
504
+ try {
505
+ host = app.createPaneHost(wnd);
506
+ } catch (err) {
507
+ embedErrorRef.current?.(err);
508
+ return undefined;
509
+ }
510
+ s.paneHost = host;
511
+
512
+ // The pane's size is this box's laid-out size, told to the pane whenever
513
+ // it changes — the same absolutize hook a <foreign> child window uses
514
+ // (src/foreignnodes.js), because onViewport fires only for scrollers.
515
+ let sentSize = null;
516
+ const syncSize = () => {
517
+ host.setRect(node.abs);
518
+ const sc = node.scale ?? 1;
519
+ const width = Math.max(1, Math.round(node.abs.width / sc));
520
+ const height = Math.max(1, Math.round(node.abs.height / sc));
521
+ if (sentSize && sentSize.width === width && sentSize.height === height) {
522
+ return;
523
+ }
524
+ sentSize = { width, height };
525
+ s.trySend?.({ type: 'pane-rect', width, height, scale: sc });
526
+ };
527
+ const origAbsolutize = node.absolutize.bind(node);
528
+ node.absolutize = (ox, oy) => {
529
+ origAbsolutize(ox, oy);
530
+ syncSize();
531
+ };
532
+ const origShift = node._shiftAbs?.bind(node);
533
+ if (origShift) {
534
+ node._shiftAbs = (dx, dy) => {
535
+ origShift(dx, dy);
536
+ host.setRect(node.abs);
537
+ };
538
+ }
539
+ if (node.abs?.width) syncSize();
540
+
541
+ const off = s.transport.onMessage((msg) => {
542
+ if (msg?.type === 'pane-present') {
543
+ host.setRect(node.abs);
544
+ host.present(msg.id);
545
+ }
546
+ });
547
+ return () => {
548
+ off();
549
+ node.absolutize = origAbsolutize;
550
+ if (origShift) node._shiftAbs = origShift;
551
+ if (s.paneHost === host) s.paneHost = null;
552
+ host.destroy();
553
+ };
554
+ }, [app, session, containerRef]);
555
+
556
+ const forward = (name, data) => (ev) => {
557
+ const s = session.current;
558
+ const node = containerRef.current;
559
+ if (!s || !node) return;
560
+ const sc = node.scale ?? 1;
561
+ const payload = {
562
+ x: Math.max(0, Math.round(ev.x * sc - node.abs.x)),
563
+ y: Math.max(0, Math.round(ev.y * sc - node.abs.y)),
564
+ rootx: Math.round(ev.x * sc),
565
+ rooty: Math.round(ev.y * sc),
566
+ buttons: ev.buttons ?? 0,
567
+ time: Date.now(),
568
+ ...data(ev),
569
+ };
570
+ try {
571
+ s.trySend?.({ type: 'pane-event', name, ev: payload });
572
+ } catch {
573
+ // the exit path owns the failure story
574
+ }
575
+ };
576
+
577
+ return h('box', {
578
+ ref: containerRef,
579
+ style,
580
+ focusable: focusable ?? true,
581
+ onMouseDown: forward('mousedown', (ev) => ({ keycode: ev.button ?? 1 })),
582
+ onMouseUp: forward('mouseup', (ev) => ({ keycode: ev.button ?? 1 })),
583
+ onMouseMove: forward('mousemove', () => ({})),
584
+ onWheel: forward('wheel', (ev) => ({
585
+ deltaX: ev.deltaX ?? 0,
586
+ deltaY: ev.deltaY ?? 0,
587
+ deltaMode: ev.deltaMode ?? 'line',
588
+ smooth: Boolean(ev.smooth),
589
+ source: 'forwarded',
590
+ })),
591
+ onKeyDown: forward('keydown', (ev) => ({
592
+ keysym: ev.keysym,
593
+ baseKeysym: ev.baseKeysym ?? ev.keysym,
594
+ codepoint: ev.codepoint,
595
+ keycode: ev.keycode ?? 0,
596
+ })),
597
+ onKeyUp: forward('keyup', (ev) => ({
598
+ keysym: ev.keysym,
599
+ baseKeysym: ev.baseKeysym ?? ev.keysym,
600
+ codepoint: ev.codepoint,
601
+ keycode: ev.keycode ?? 0,
602
+ })),
603
+ });
604
+ }
package/src/glnodes.js CHANGED
@@ -194,6 +194,21 @@ export class GlAreaNode extends Node {
194
194
  // GL draws into the window itself: no 2d backing pixmap, and the
195
195
  // frame clock is ours to drive
196
196
  backingStore: false,
197
+ // The wheel, and only the wheel. A GL surface owns a real X window, so
198
+ // the pointer events over it are delivered *there* rather than to the
199
+ // window the rest of the tree is hit-tested in — which is why nothing
200
+ // over a `<glarea>` reached an application before. Selecting it here
201
+ // and handing it back to the owning window's manager (`_onWheel`
202
+ // below) is the whole of it: from there the event is an ordinary
203
+ // synthetic `Wheel` at this node, so it bubbles, `preventDefault()`
204
+ // takes it back for a scene that zooms instead, and the default action
205
+ // scrolls the nearest container the way it does anywhere else.
206
+ //
207
+ // Selected unconditionally rather than when a handler is declared: the
208
+ // default action is what a reader expects from a wheel over a page,
209
+ // and an element that wants it back has `preventDefault()`. One
210
+ // ButtonPress per notch is not a cost worth a conditional.
211
+ onWheel: (ev) => this._onWheel(ev),
197
212
  });
198
213
  this.window = wnd;
199
214
  this.rect = rect;
@@ -313,6 +328,34 @@ export class GlAreaNode extends Node {
313
328
  if (this.props.frameLoop === 'always') this.requestFrame();
314
329
  }
315
330
 
331
+ /**
332
+ * A wheel over the surface, handed to the window the tree lives in.
333
+ *
334
+ * ntk reports the position inside *this* window; the manager hit-tests in
335
+ * the owning window's space, so the node's own origin goes back on. Both
336
+ * are device pixels — the scale is applied at the far end, where a handler
337
+ * reads `ev.x` (src/events.js).
338
+ *
339
+ * Smooth deltas are not part of this yet: XI2 is selected on the window
340
+ * the manager owns, not on this child, so a touchpad's fractions arrive
341
+ * here as whole notches from buttons 4-7.
342
+ */
343
+ _onWheel(native) {
344
+ const events = this.root?.events;
345
+ if (!events || this.destroyed) return;
346
+ events._onWheel(
347
+ {
348
+ ...native,
349
+ x: (native.x ?? 0) + this.abs.x,
350
+ y: (native.y ?? 0) + this.abs.y,
351
+ },
352
+ // named rather than hit-tested: a window-owning child is not in its
353
+ // parent's paint order, so the hit test would answer with the box
354
+ // behind this surface
355
+ this,
356
+ );
357
+ }
358
+
316
359
  applyProps(newProps, oldProps) {
317
360
  super.applyProps(newProps, oldProps);
318
361
  // onDraw/clearColor are read at frame time, so any update is a new frame
package/src/globalmenu.js CHANGED
@@ -67,6 +67,7 @@ import {
67
67
  snapshot,
68
68
  IdAllocator,
69
69
  } from './dbusmenu.js';
70
+ import { useAppOrNull } from './appcontext.js';
70
71
  import { useTopLevelWindow, windowIdOf } from './windowid.js';
71
72
 
72
73
  export const REGISTRAR_NAME = 'com.canonical.AppMenu.Registrar';
@@ -649,6 +650,7 @@ export function useGlobalMenu(
649
650
  { onSelect, onAboutToShow, enabled = true } = {},
650
651
  ) {
651
652
  const target = useTopLevelWindow();
653
+ const app = useAppOrNull();
652
654
  const [exported, setExported] = useState(false);
653
655
  const exportRef = useRef(null);
654
656
 
@@ -661,7 +663,7 @@ export function useGlobalMenu(
661
663
 
662
664
  useEffect(() => {
663
665
  if (!enabled) return undefined;
664
- const owner = new GlobalMenuExport({
666
+ const config = {
665
667
  getMenus: () => live.current.menus ?? [],
666
668
  onSelect: (item) => {
667
669
  item.onSelect?.(item);
@@ -670,7 +672,13 @@ export function useGlobalMenu(
670
672
  onAboutToShow: (item) => live.current.onAboutToShow?.(item),
671
673
  target,
672
674
  onChange: setExported,
673
- });
675
+ };
676
+ // The transport is the backend's where it has one — the Cocoa backend
677
+ // owns the macOS menu bar and answers immediately — and D-Bus with the
678
+ // registrar dance everywhere else. Same owner contract either way.
679
+ const owner = app?.createGlobalMenuExport
680
+ ? app.createGlobalMenuExport(config)
681
+ : new GlobalMenuExport(config);
674
682
  exportRef.current = owner;
675
683
  owner.start().catch(() => {});
676
684
  return () => {
@@ -678,7 +686,7 @@ export function useGlobalMenu(
678
686
  setExported(false);
679
687
  owner.stop().catch(() => {});
680
688
  };
681
- }, [enabled, target]);
689
+ }, [enabled, target, app]);
682
690
 
683
691
  useEffect(() => {
684
692
  exportRef.current?.update(menus ?? []);
package/src/index.d.ts CHANGED
@@ -170,6 +170,16 @@ export interface ErrorInfo {
170
170
  }
171
171
 
172
172
  export interface RootOptions {
173
+ /**
174
+ * Which display system a root that opens its own connection talks to.
175
+ * `'auto'` (the default) uses the native Core Animation backend on macOS
176
+ * and X11 via `$DISPLAY` everywhere else; on a mac without the
177
+ * `@windowkit/appkit` bridge installed it falls back to X11. Naming
178
+ * `'cocoa'` makes the bridge required. `REACT_X11_BACKEND` overrides.
179
+ * Ignored when {@link RootOptions.app} is passed — a connection already
180
+ * is a backend.
181
+ */
182
+ backend?: 'auto' | 'x11' | 'cocoa';
173
183
  /** `':1'`, `'host:0.0'`, or a unix socket path. Defaults to `$DISPLAY`. */
174
184
  display?: string;
175
185
  /**