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.
- package/README.md +5 -3
- package/package.json +12 -5
- package/src/Reconciler.js +76 -11
- package/src/anchor.js +15 -5
- package/src/appcontext.js +27 -7
- package/src/cocoa/app.js +557 -0
- package/src/cocoa/bezels.js +161 -0
- package/src/cocoa/context2d.js +679 -0
- package/src/cocoa/fonts.js +541 -0
- package/src/cocoa/glarea.js +321 -0
- package/src/cocoa/globalmenu.js +149 -0
- package/src/cocoa/keymap.js +86 -0
- package/src/cocoa/native.js +51 -0
- package/src/cocoa/panehost.js +50 -0
- package/src/cocoa/panewindow.js +236 -0
- package/src/cocoa/presenter.js +837 -0
- package/src/cocoa/window.js +383 -0
- package/src/components/Button.js +68 -0
- package/src/components/Checkbox.js +43 -0
- package/src/components/Radio.js +37 -1
- package/src/components/Select.js +73 -28
- package/src/components/Slider.js +59 -0
- package/src/components/Switch.js +52 -0
- package/src/components/native.js +142 -0
- package/src/decorations.js +21 -24
- package/src/editmenu.js +23 -14
- package/src/events.js +11 -2
- package/src/frame/childmain.js +9 -0
- package/src/frame/index.js +162 -2
- package/src/glnodes.js +43 -0
- package/src/globalmenu.js +11 -3
- package/src/index.d.ts +10 -0
- package/src/nodes.js +107 -33
- package/src/paintcache.js +15 -4
- package/src/palette.js +14 -0
- package/src/styles.js +5 -0
- package/src/types/elements.d.ts +10 -0
package/src/frame/index.js
CHANGED
|
@@ -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.
|
|
370
|
-
height: Math.
|
|
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
|
|
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
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
(
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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:
|
|
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.
|
|
4182
|
-
*
|
|
4183
|
-
*
|
|
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
|
|
4188
|
-
*
|
|
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,
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
package/src/types/elements.d.ts
CHANGED
|
@@ -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
|
}
|