react-x11 2.2.1 → 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.
@@ -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
  /**
package/src/nodes.js CHANGED
@@ -1547,6 +1547,13 @@ export class Node {
1547
1547
  // delete themselves as they land; a loop entry (`loop: true`) is removed
1548
1548
  // by `_updateLoops` and by nothing else
1549
1549
  this._anim = null;
1550
+ // False until the first frame places this node (`absolutize`). Read by
1551
+ // `_retarget`: a style can be re-resolved several times between
1552
+ // construction and that first frame — the attach-time theme merge is the
1553
+ // common one, replacing a detached resolution against the desktop
1554
+ // palette with one against the app's own — and none of those is a
1555
+ // *change* the user saw, so no transition may start from it.
1556
+ this._placed = false;
1550
1557
  // the loops this node's style declares, whether or not they are running
1551
1558
  this._loops = null;
1552
1559
  // `resolvedTextStyle()`'s cache: this node's own text style over what it
@@ -1701,24 +1708,32 @@ export class Node {
1701
1708
  }
1702
1709
  return this.style;
1703
1710
  }
1704
- for (const prop of Object.keys(target)) {
1705
- const to = target[prop];
1706
- const from = displayed[prop];
1707
- if (from === to || from === undefined) continue;
1708
- const duration = transitionFor(target, prop);
1709
- if (duration <= 0) continue;
1710
- if (interpolate(from, to, 0.5) === null) continue; // no midpoint: snap
1711
- (this._anim ??= new Map()).set(prop, {
1712
- from,
1713
- to,
1714
- duration,
1715
- // *now*, not the last frame's timestamp: between two user actions
1716
- // the window is idle and draws nothing, so the previous frame can
1717
- // be seconds old and the first tick would then find the
1718
- // transition already over and jump straight to the end
1719
- start: now(),
1720
- });
1721
- this.root?._startAnimating(this);
1711
+ // Only for a node the user has seen (`_placed`): between construction
1712
+ // and the first frame a style is re-resolved several times — attach
1713
+ // merges the real theme over the detached resolution's desktop palette,
1714
+ // queries settle and animating any of those would travel from a value
1715
+ // that was never on screen. An inserted element *appears* at its style;
1716
+ // transitions start on later changes, which is CSS's rule too.
1717
+ if (this._placed) {
1718
+ for (const prop of Object.keys(target)) {
1719
+ const to = target[prop];
1720
+ const from = displayed[prop];
1721
+ if (from === to || from === undefined) continue;
1722
+ const duration = transitionFor(target, prop);
1723
+ if (duration <= 0) continue;
1724
+ if (interpolate(from, to, 0.5) === null) continue; // no midpoint: snap
1725
+ (this._anim ??= new Map()).set(prop, {
1726
+ from,
1727
+ to,
1728
+ duration,
1729
+ // *now*, not the last frame's timestamp: between two user actions
1730
+ // the window is idle and draws nothing, so the previous frame can
1731
+ // be seconds old — and the first tick would then find the
1732
+ // transition already over and jump straight to the end
1733
+ start: now(),
1734
+ });
1735
+ this.root?._startAnimating(this);
1736
+ }
1722
1737
  }
1723
1738
  // After the transitions, before the style is assembled: a loop that just
1724
1739
  // arrived contributes a value to this very swap, so the first frame the
@@ -3253,6 +3268,9 @@ export class Node {
3253
3268
  }
3254
3269
 
3255
3270
  absolutize(originX, originY) {
3271
+ // before the yoga check, so a span — placed by its paragraph, no box of
3272
+ // its own — counts as on screen too
3273
+ this._placed = true;
3256
3274
  if (!this.yoga) return;
3257
3275
  this._assignAbs(
3258
3276
  originX + this.yoga.getComputedLeft(),
@@ -4176,19 +4194,28 @@ export class Node {
4176
4194
  * One blurred shadow, through the paint cache when there is one.
4177
4195
  *
4178
4196
  * The surface is the shadow's rectangle plus `pad` on every side, and the
4179
- * padding is load-bearing: the convolution reads outside the picture as
4197
+ * padding is load-bearing: a convolution reads outside the picture as
4180
4198
  * transparent, so a kernel that runs off the edge ends the shadow in a
4181
- * straight line. The blur is set on the *picture* rather than baked into
4182
- * the pixels, which is why it survives in a cached entry and why the
4183
- * surface itself is a plain white rectangle.
4199
+ * straight line. `blurKernel` takes that reach from the same function
4200
+ * ntk builds the kernel with, so the two cannot drift apart.
4201
+ *
4202
+ * The blur is **baked into the pixels** by `blurCoverage` (ntk 8.6,
4203
+ * ntk#335) rather than set as a filter on the picture. That is the
4204
+ * difference between a cached shadow and a cached shadow that costs
4205
+ * nothing to draw: a picture's filter is re-applied by the server on every
4206
+ * composite, so the entry would hit, re-render nothing, and still pay its
4207
+ * whole kernel every frame — 244M multiply-accumulates for one card-sized
4208
+ * shadow, which was 1.6s per `:hover` on XQuartz. Baked, what the cache
4209
+ * holds composites as an ordinary mask however wide the blur was, and the
4210
+ * two separable passes run once per distinct geometry.
4184
4211
  *
4185
4212
  * `maxPixels` is raised well above the cache's default: a card's shadow is
4186
4213
  * as big as the card, an entry for one is a8 (a byte a pixel), and the
4187
- * thing being avoided a convolution per frame over the whole box — is
4188
- * exactly the cost the default cap exists to bound elsewhere.
4214
+ * thing being avoided is exactly the cost the default cap bounds
4215
+ * elsewhere.
4189
4216
  */
4190
4217
  _paintBlurredShadow(ctx, rect, radius, blur, color) {
4191
- const { sigma, size, pad } = blurKernel(blur);
4218
+ const { sigma, pad } = blurKernel(blur);
4192
4219
  // integral, because the surface is pixels; the blur is far wider than
4193
4220
  // the rounding, so nothing about the result is visibly quantized
4194
4221
  const width = Math.round(rect.width);
@@ -4202,6 +4229,10 @@ export class Node {
4202
4229
  format: 'a8',
4203
4230
  tint: color,
4204
4231
  maxPixels: 1024 * 1024,
4232
+ // Cache on the first sighting rather than the second: what the gate
4233
+ // saves elsewhere is a cheap redraw, and what it costs here is a whole
4234
+ // gaussian — the one thing this entry exists to avoid running twice.
4235
+ eager: true,
4205
4236
  draw: (sctx, box) => {
4206
4237
  // full coverage: the colour arrives at composite time
4207
4238
  sctx.fillStyle = '#ffffff';
@@ -4212,7 +4243,7 @@ export class Node {
4212
4243
  );
4213
4244
  sctx.fill();
4214
4245
  },
4215
- after: (surface) => surface.picture().setBlurFilter(size, sigma),
4246
+ after: (surface) => ntk.blurCoverage(surface, sigma),
4216
4247
  live: () => this._paintShadowLive(ctx, plan),
4217
4248
  };
4218
4249
  const cache = this.root?._paintCache;
@@ -4225,7 +4256,9 @@ export class Node {
4225
4256
  * build, an entry too big for the budget, and the first frame of a shadow
4226
4257
  * the cache has only seen once. A surface per frame is what a shadow costs
4227
4258
  * without a cache; it is still one composite on the wire, and the
4228
- * alternative is not painting it.
4259
+ * alternative is not painting it. The blur is baked here too: two
4260
+ * separable passes and a plain composite still beat one composite through
4261
+ * a k x k kernel, by the ratio of 2k to k squared.
4229
4262
  */
4230
4263
  _paintShadowLive(ctx, plan) {
4231
4264
  if (typeof ntk.Surface !== 'function' || !this.app?.display?.Render) return;
@@ -4239,7 +4272,10 @@ export class Node {
4239
4272
  surface.render((sctx) =>
4240
4273
  plan.draw(sctx, { x: 0, y: 0, width: plan.width, height: plan.height }),
4241
4274
  );
4242
- plan.after(surface);
4275
+ // `after` may hand back a *different* surface — the blur is baked into
4276
+ // a second one and the sharp copy destroyed — so both the drawing and
4277
+ // the cleanup below follow what it returned.
4278
+ surface = plan.after(surface) ?? surface;
4243
4279
  const before = ctx.fillStyle;
4244
4280
  ctx.fillStyle = plan.tint;
4245
4281
  ctx.drawImage(surface, plan.x, plan.y);
@@ -5479,6 +5515,7 @@ export const Scrollable = (Base) =>
5479
5515
  }
5480
5516
 
5481
5517
  absolutize(originX, originY) {
5518
+ this._placed = true;
5482
5519
  if (!this.yoga) return;
5483
5520
  this._assignAbs(
5484
5521
  originX + this.yoga.getComputedLeft(),
@@ -6530,13 +6567,16 @@ export function openEditMenu(node, at, actions = {}) {
6530
6567
  if (items.length === 0) return;
6531
6568
 
6532
6569
  const style = node.resolvedTextStyle();
6570
+ // `at` is `{x: ev.x, y: ev.y}` per the doc above — logical, like every
6571
+ // coordinate a handler reads — and everything below is device: the
6572
+ // geometry takes the scale so its chrome lands on the same grid as the
6573
+ // device-sized text it measures.
6574
+ const s = node.scale;
6533
6575
  const geometry = editMenuGeometry(
6534
6576
  items,
6535
6577
  (text) => app?.fonts?.layout(text, style)?.width,
6578
+ s,
6536
6579
  );
6537
- // `at` is `{x: ev.x, y: ev.y}` per the doc above — logical, like every
6538
- // coordinate a handler reads — and the origin math below is device.
6539
- const s = node.scale;
6540
6580
  const deviceAt = at && {
6541
6581
  ...at,
6542
6582
  ...(Number.isFinite(at.x) && { x: at.x * s }),
@@ -10227,8 +10267,20 @@ export class WindowNode extends Scrollable(Node) {
10227
10267
  * before laying out. This is the whole reason a size query may carry
10228
10268
  * layout properties while a state block may not: it only ever runs inside
10229
10269
  * a layout pass the resize already required.
10230
- */
10231
- _resolveSizeQueries(width, height) {
10270
+ *
10271
+ * Callers pass the size in device pixels — it comes off the window or out
10272
+ * of yoga, and both live on the device grid — but `querySize` is stored
10273
+ * in **logical** pixels, because that is the unit the thresholds were
10274
+ * written in: `'@width >= 620'` sits in a style block next to `width:
10275
+ * 620`, and the same number must mean the same thing. At scale 1 the two
10276
+ * coincide, which is how comparing device pixels survived every 1x
10277
+ * display it was ever run on and broke on the first retina one (every
10278
+ * query read double, so none of them ever changed answer under a drag).
10279
+ */
10280
+ _resolveSizeQueries(deviceWidth, deviceHeight) {
10281
+ const s = this.scale || 1;
10282
+ const width = deviceWidth / s;
10283
+ const height = deviceHeight / s;
10232
10284
  if (this._sizeQueryNodes.size === 0) {
10233
10285
  this.querySize = this.querySize ?? { width, height };
10234
10286
  return false;
@@ -10452,6 +10504,11 @@ export class WindowNode extends Scrollable(Node) {
10452
10504
  }
10453
10505
  (this._frameReasons ??= new Set()).add(reason);
10454
10506
  }
10507
+ // A retained presenter keeps a per-node diff instead of damage rects,
10508
+ // and this is the one channel every change already announces itself on
10509
+ // (docs/macos.md §"One renderer, two presenters"). Feature-detected: an
10510
+ // ntk window has no ear here and the X11 path is byte-identical.
10511
+ this.window?.noteInvalidate?.(damage, layoutChanged, reason);
10455
10512
  if (layoutChanged) {
10456
10513
  this.needsLayout = true;
10457
10514
  // The content floors are measured from the tree, so anything that
@@ -10616,6 +10673,7 @@ export class WindowNode extends Scrollable(Node) {
10616
10673
  this.yoga.setHeight(height);
10617
10674
  this.yoga.calculateLayout(width, height, this._rootDirection);
10618
10675
  this.abs = { x: 0, y: 0, width, height };
10676
+ this._placed = true;
10619
10677
  // the root's rect is written here, not through _assignAbs, so its
10620
10678
  // cached hit reach is dropped here too (children bubble their own)
10621
10679
  this._hitBoundsCache = null;
@@ -10691,6 +10749,16 @@ export class WindowNode extends Scrollable(Node) {
10691
10749
  );
10692
10750
  }
10693
10751
  this._fullRepaintCause = null;
10752
+ // A retained presenter takes the frame from here: the model half above —
10753
+ // animations, layout, absolutize, the scroll offsets — is shared, and
10754
+ // what changes per backend is how a frame reaches the screen. The damage
10755
+ // list was still taken (its bookkeeping is what keeps the two paths one
10756
+ // code) and is simply not consumed; the presenter diffs at the layer.
10757
+ if (typeof this.window.presentFrame === 'function') {
10758
+ this.window.presentFrame(this, damage);
10759
+ this.app._reactX11Startup?.painted();
10760
+ return;
10761
+ }
10694
10762
  if (typeof this.window.getContext !== 'function') return; // headless mock
10695
10763
  // ntk getContext creates a fresh context (with window-event
10696
10764
  // subscriptions) on every call — cache one per window
@@ -10711,6 +10779,12 @@ export class WindowNode extends Scrollable(Node) {
10711
10779
  // after every region: an entry drawn in one damage rect must not be
10712
10780
  // evicted before the next rect of the same frame asks for it
10713
10781
  this._paintCache?.endFrame();
10782
+ // The swapchain seam: a backend presenting from double buffers has to
10783
+ // know exactly which pixels each flush touched — several flushes can
10784
+ // land between two presents, so reading only the last frame's rects
10785
+ // would leave the flipped-in back buffer stale where an earlier flush
10786
+ // painted. Feature-detected like presentFrame; null means everything.
10787
+ this.window.noteFrameDamage?.(damage ?? null);
10714
10788
  if (frameHook) {
10715
10789
  frameHook({
10716
10790
  root: this,
package/src/paintcache.js CHANGED
@@ -232,7 +232,13 @@ export class PaintCache {
232
232
  }
233
233
 
234
234
  this.stats.misses++;
235
- const seen = (this.pending.get(plan.key) ?? 0) + 1;
235
+ // `eager` skips the gate below for a drawing whose *live* path is the
236
+ // expensive thing — a blurred shadow, whose first sighting otherwise
237
+ // runs a convolution that is then thrown away, and a second one to keep.
238
+ // The gate is there so a page cycling unique content cannot fill the
239
+ // cache with entries drawn once; an eager plan opts out of that
240
+ // protection deliberately, and the LRU budget is what still bounds it.
241
+ const seen = plan.eager ? 2 : (this.pending.get(plan.key) ?? 0) + 1;
236
242
  if (seen < 2) {
237
243
  if (this.pending.size >= MAX_PENDING) this.pending.clear();
238
244
  this.pending.set(plan.key, seen);
@@ -260,12 +266,17 @@ export class PaintCache {
260
266
  surface.render((sctx) =>
261
267
  plan.draw(this.verify ? recordingContext(sctx, state) : sctx, box),
262
268
  );
263
- plan.after?.(surface);
269
+ // `after` may hand back a *different* surface than it was given — a
270
+ // blurred shadow bakes its convolution into a second one and destroys
271
+ // the sharp copy, so that what this entry holds composites as a plain
272
+ // mask instead of re-running a kernel on every blit. What it returns
273
+ // is what the cache owns from here on.
274
+ const final = plan.after?.(surface) ?? surface;
264
275
  this.stats.renders++;
265
276
  return {
266
277
  key: plan.key,
267
- surface,
268
- bytes: surface.bytes,
278
+ surface: final,
279
+ bytes: final.bytes,
269
280
  digest: this.verify ? state.digest : 0,
270
281
  };
271
282
  } catch (err) {
package/src/palette.js CHANGED
@@ -222,6 +222,19 @@ export const DefaultTheme = {
222
222
  // than it looks next to a CSS padding for that reason: 12 here is about
223
223
  // what 8 came to once a typical face's ascent had been added on.
224
224
  paddingY: 12,
225
+ // Which scheme this palette *is* — 'light' or 'dark'. Not a colour but a
226
+ // fact about the colours, for the consumers that have to match them with
227
+ // something they do not paint themselves: the Cocoa backend picks the
228
+ // AppKit appearance its native control bezels are rendered in from this,
229
+ // so a pinned-light app gets light bezels on a dark desktop. A custom
230
+ // dark palette built over the light base should say `scheme: 'dark'`.
231
+ scheme: 'light',
232
+ // How the core controls render where the backend offers the platform's
233
+ // own: `'auto'` (native where supported — today the Cocoa backend),
234
+ // `'native'` (ask for it; warns and falls back to drawn where there is
235
+ // none) or `'drawn'` (always the themed rendering). Per-instance escape
236
+ // hatch: `native={false}` on the one custom-branded control.
237
+ controls: 'auto',
225
238
  };
226
239
 
227
240
  // Which pressed token is derived from which pair, when the palette does not
@@ -332,6 +345,7 @@ export function resolveTheme(value, base = DefaultTheme) {
332
345
  * theme after it gets to do.
333
346
  */
334
347
  export const DarkTheme = resolveTheme({
348
+ scheme: 'dark',
335
349
  // A near-black with a little blue in it rather than #000: pure black shows
336
350
  // every seam between a window and the widgets on it, and no desktop's dark
337
351
  // theme uses it.
package/src/styles.js CHANGED
@@ -466,6 +466,11 @@ const isState = (key) => key.charCodeAt(0) === 58; /* ':' */
466
466
  * what a style can usefully ask about here is the window it is being laid
467
467
  * out in, not the screen.
468
468
  *
469
+ * The threshold is **logical** pixels, like every other number in a style
470
+ * block: `'@width >= 620'` flips where `width: 620` would fit, whatever
471
+ * the display scale (the window node divides its device size out before
472
+ * matching — `_resolveSizeQueries`).
473
+ *
469
474
  * Unlike a state block, a size query *may* set layout properties. That is
470
475
  * not an inconsistency: pointer state changes must never reflow the tree,
471
476
  * but a size query is only ever re-evaluated during a layout pass that a
@@ -23,6 +23,7 @@ import type {
23
23
  SubmitEvent,
24
24
  SyntheticEvent,
25
25
  ViewportEvent,
26
+ WheelEvent,
26
27
  WindowResizeEvent,
27
28
  } from './events.js';
28
29
 
@@ -841,6 +842,15 @@ export interface GlAreaProps extends DrawnProps<DrawnNode> {
841
842
  onDraw?: (gl: any, info: DrawInfo) => void;
842
843
  /** No GL surface — no GLX, or no matching visual. */
843
844
  onError?: (err: Error) => void;
845
+ /**
846
+ * The wheel over the surface. Inherited from `EventHandlers` like every
847
+ * other element's, and listed here because it is the **only** pointer
848
+ * event a `<glarea>` currently reports: the surface owns its own X window,
849
+ * so it selects the wheel there and hands it to the window's event manager
850
+ * (see docs/elements.md). Deltas are pixels, `preventDefault()` takes the
851
+ * default scroll action back, and it bubbles from this node.
852
+ */
853
+ onWheel?: (ev: WheelEvent<DrawnNode>) => void;
844
854
  /** A click inside the surface that hit no mesh. */
845
855
  onPointerMissed?: (ev: MouseEvent<DrawnNode>) => void;
846
856
  }