react-x11 2.49.0 → 2.51.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 -1
- package/package.json +4 -4
- package/src/ClickToComponent.js +11 -132
- package/src/DevToolsIntegration.js +126 -42
- package/src/Reconciler.js +26 -10
- package/src/cli/dev.js +162 -19
- package/src/cli/errorwindow.js +368 -0
- package/src/cli/index.js +4 -0
- package/src/cocoa/app.js +22 -0
- package/src/cocoa/presenter.js +3 -1
- package/src/cocoa/promotion.js +10 -11
- package/src/cocoa/relaunch.js +114 -15
- package/src/cocoa/window.js +98 -15
- package/src/currentcolor.js +28 -0
- package/src/decorations.js +21 -9
- package/src/editor.js +151 -0
- package/src/foreignnodes.js +8 -1
- package/src/gloverlay.js +2 -1
- package/src/node.d.ts +5 -0
- package/src/nodes/animation.js +31 -3
- package/src/nodes/boxpaint.js +34 -8
- package/src/nodes/cascade.js +50 -3
- package/src/nodes/damage.js +2 -0
- package/src/nodes/layout.js +9 -17
- package/src/nodes/moveblit.js +5 -5
- package/src/nodes/node.js +8 -0
- package/src/nodes/paint.js +4 -2
- package/src/nodes/queries.js +46 -34
- package/src/nodes/scrollable.js +4 -1
- package/src/nodes/scrollblit.js +10 -7
- package/src/nodes/styling.js +176 -21
- package/src/nodes/window/capabilities.js +1 -1
- package/src/nodes/window/floors.js +63 -18
- package/src/nodes/window/size.js +94 -19
- package/src/refresh/loader.d.ts +16 -0
- package/src/refresh/loader.js +58 -8
- package/src/shorthands.js +1266 -0
- package/src/styles.js +370 -131
- package/src/types/elements.d.ts +5 -2
- package/src/types/style.d.ts +163 -39
- package/src/types/system.d.ts +4 -1
package/src/cocoa/promotion.js
CHANGED
|
@@ -42,7 +42,7 @@ import { now } from '../nodes/animation.js';
|
|
|
42
42
|
import { BoxNode } from '../nodes/box.js';
|
|
43
43
|
import { addDamageRect, damageToPaint } from '../nodes/damage.js';
|
|
44
44
|
import { intersectRects } from '../nodes/rects.js';
|
|
45
|
-
import { resolveBorderWidths } from '../styles.js';
|
|
45
|
+
import { radiusOf, resolveBorderWidths } from '../styles.js';
|
|
46
46
|
import {
|
|
47
47
|
LayerAnimations,
|
|
48
48
|
RASTER_PAD,
|
|
@@ -841,7 +841,7 @@ export class CocoaPromotion {
|
|
|
841
841
|
cut &&
|
|
842
842
|
!parent.isWindow &&
|
|
843
843
|
parent.clipsChildren?.() &&
|
|
844
|
-
!(parent.style
|
|
844
|
+
!(radiusOf(parent.style, parent.abs) > 0)
|
|
845
845
|
) {
|
|
846
846
|
const inner = insetRect(parent.abs, borderReach(parent));
|
|
847
847
|
// a clip with round corners cut again is no one box's shape: the
|
|
@@ -872,13 +872,15 @@ export class CocoaPromotion {
|
|
|
872
872
|
!cut.clip &&
|
|
873
873
|
!parent.isWindow &&
|
|
874
874
|
parent.clipsChildren?.() &&
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
875
|
+
radiusOf(parent.style, parent.abs) > 0 &&
|
|
876
|
+
!containsRect(
|
|
877
|
+
insetRect(parent.abs, radiusOf(parent.style, parent.abs)),
|
|
878
|
+
bounds,
|
|
879
|
+
)
|
|
878
880
|
) {
|
|
879
881
|
const border = borderReach(parent);
|
|
880
882
|
cut.clip = insetRect(parent.abs, border);
|
|
881
|
-
cut.radius = Math.max(0, parent.style.
|
|
883
|
+
cut.radius = Math.max(0, radiusOf(parent.style, parent.abs) - border);
|
|
882
884
|
roundCut = true;
|
|
883
885
|
bounds = intersectRects(bounds, cut.clip);
|
|
884
886
|
if (!bounds) {
|
|
@@ -906,11 +908,8 @@ export class CocoaPromotion {
|
|
|
906
908
|
return false;
|
|
907
909
|
}
|
|
908
910
|
if (parent.clipsChildren?.() && !roundCut) {
|
|
909
|
-
const radius = parent.style
|
|
910
|
-
const clip =
|
|
911
|
-
typeof radius === 'number' && radius > 0
|
|
912
|
-
? insetRect(parent.abs, radius)
|
|
913
|
-
: parent.abs;
|
|
911
|
+
const radius = radiusOf(parent.style, parent.abs);
|
|
912
|
+
const clip = radius > 0 ? insetRect(parent.abs, radius) : parent.abs;
|
|
914
913
|
if (!containsRect(clip, bounds)) return false;
|
|
915
914
|
}
|
|
916
915
|
}
|
package/src/cocoa/relaunch.js
CHANGED
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
// name: it runs before the entry is even loaded, and skips the checks.
|
|
23
23
|
import fs from 'node:fs';
|
|
24
24
|
import { createRequire } from 'node:module';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
25
27
|
import { isMainThread, SHARE_ENV, Worker } from 'node:worker_threads';
|
|
26
28
|
|
|
27
29
|
import { loadNative } from './native.js';
|
|
@@ -156,6 +158,15 @@ const ENTRY_POINT = new Set([
|
|
|
156
158
|
// ts-node/esm`, `--experimental-loader ./hooks.mjs`.
|
|
157
159
|
const LOADER_FLAG = /^--(?:import|loader|experimental-loader)(?:=|$)/;
|
|
158
160
|
|
|
161
|
+
// The module Bun runs an entry from (`startedRunning`): one it writes
|
|
162
|
+
// itself, which imports the entry (measured, Bun 1.4).
|
|
163
|
+
const BUN_MAIN = 'bun:main';
|
|
164
|
+
|
|
165
|
+
// What Bun parses an entry as, by its extension: a .ts file may hold a
|
|
166
|
+
// `<T>x` cast, which TSX reads as an element, and JSX is allowed in a .js
|
|
167
|
+
// file, where the JSX reader takes any script a plain one would.
|
|
168
|
+
const SCAN_LOADER = { '.tsx': 'tsx', '.ts': 'ts', '.mts': 'ts', '.cts': 'ts' };
|
|
169
|
+
|
|
159
170
|
/**
|
|
160
171
|
* Why this import should not move the app onto a worker, or null when it
|
|
161
172
|
* should. A pure answer over what the process says about itself, so that
|
|
@@ -235,8 +246,10 @@ export function describeProcess(moduleUrl) {
|
|
|
235
246
|
platform: process.platform,
|
|
236
247
|
env: process.env,
|
|
237
248
|
execArgv: process.execArgv,
|
|
238
|
-
// Node's major version; Bun answers with one of its own choosing
|
|
239
|
-
|
|
249
|
+
// Node's major version; Bun answers with one of its own choosing, and a
|
|
250
|
+
// web page's `process` (the playground's) names no node at all — read
|
|
251
|
+
// here before any veto, so a throw would end every import of react-x11
|
|
252
|
+
node: bun ? null : Number(process.versions?.node?.split('.')[0]) || null,
|
|
240
253
|
entry: typeof entry === 'string' && fs.existsSync(entry) ? entry : null,
|
|
241
254
|
compiled: compiledExecutable(bun),
|
|
242
255
|
late: startedRunning(bun, moduleUrl, entry),
|
|
@@ -278,14 +291,14 @@ function compiledExecutable(bun) {
|
|
|
278
291
|
* and later evaluate an `import()` the entry makes before the loop's first
|
|
279
292
|
* turn, so an app that made one moved and ran its entry twice.
|
|
280
293
|
*
|
|
281
|
-
* Bun reports
|
|
282
|
-
*
|
|
283
|
-
*
|
|
294
|
+
* Bun reports no loop there, its `nodeTiming` a stand-in at 1 once
|
|
295
|
+
* `node:perf_hooks` is loaded, and leaves no frame below a module it
|
|
296
|
+
* evaluates, so it is read its own way (`bunStartedRunning`).
|
|
284
297
|
*/
|
|
285
298
|
function startedRunning(bun, moduleUrl, entry) {
|
|
286
299
|
if (!bun) return !evaluatedFromEntryPoint();
|
|
287
300
|
try {
|
|
288
|
-
return
|
|
301
|
+
return bunStartedRunning(bun, moduleUrl, entry);
|
|
289
302
|
} catch {
|
|
290
303
|
return false;
|
|
291
304
|
}
|
|
@@ -293,13 +306,106 @@ function startedRunning(bun, moduleUrl, entry) {
|
|
|
293
306
|
|
|
294
307
|
/** Whether this stack, async frames and all, comes from `ENTRY_POINT`. */
|
|
295
308
|
function evaluatedFromEntryPoint() {
|
|
309
|
+
const sites = callSites();
|
|
310
|
+
if (!sites) return false;
|
|
311
|
+
for (const site of sites) {
|
|
312
|
+
const file = site.getFileName() ?? '';
|
|
313
|
+
if (ENTRY_POINT.has(file)) return true;
|
|
314
|
+
// the app's own code awaits this import, so it has run already
|
|
315
|
+
if (site.isAsync() && !file.startsWith('node:')) return false;
|
|
316
|
+
}
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* `startedRunning` in Bun, read from three things it shows of the entry's
|
|
322
|
+
* run (each measured, Bun 1.4).
|
|
323
|
+
*
|
|
324
|
+
* Its module registry. Bun runs the entry from a module it writes itself,
|
|
325
|
+
* `bun:main`, which imports it, and `require.cache` lists an ES module once
|
|
326
|
+
* it has been evaluated: `bun:main` is there once the entry has finished,
|
|
327
|
+
* top-level awaits and all, and an import made after that is late. Before
|
|
328
|
+
* then the entry's own record says nothing either way. Bun marks a CommonJS
|
|
329
|
+
* module `loaded` before its body runs, which read as late from a CommonJS
|
|
330
|
+
* entry's first line, so it never moved; and an ES module has no record
|
|
331
|
+
* until it is evaluated, which read as not started for an entry awaiting
|
|
332
|
+
* its `import()` of react-x11, so it moved and ran a second time on the
|
|
333
|
+
* worker.
|
|
334
|
+
*
|
|
335
|
+
* The stack. Bun's loader evaluates an ES module's imports with no frame
|
|
336
|
+
* below the module, so a frame below react-x11 is code that loaded it
|
|
337
|
+
* synchronously. A CommonJS entry is in `require.cache` from its first line,
|
|
338
|
+
* and on its first run its own wrapper is the outermost frame. Bun runs the
|
|
339
|
+
* nextTicks and microtasks that first run queues before it evaluates
|
|
340
|
+
* `bun:main`: a callback there in another module reads as late, and one in
|
|
341
|
+
* the entry itself as its first run, unless in strict code it ends by
|
|
342
|
+
* returning its `require()`, which JavaScriptCore makes a tail call that
|
|
343
|
+
* leaves it no frame. For an ES module entry, a frame below react-x11 is a
|
|
344
|
+
* CommonJS module the entry imports, on its first run, or a `require()` in
|
|
345
|
+
* a module's body, and a bundle carries react-x11 inside the entry itself:
|
|
346
|
+
* each of them the start.
|
|
347
|
+
*
|
|
348
|
+
* The entry's static imports. An ES module's body runs once every module it
|
|
349
|
+
* imports statically has been evaluated: while one has not, the entry has
|
|
350
|
+
* not run a line and react-x11 is part of its graph, an `import()` that one
|
|
351
|
+
* of those modules awaits included; once all have, the entry's body is
|
|
352
|
+
* running or waiting on a top-level await, and asked for react-x11 itself.
|
|
353
|
+
* Read with Bun's own scanner and resolver, and what they cannot answer
|
|
354
|
+
* throws, which reads as the start.
|
|
355
|
+
*/
|
|
356
|
+
function bunStartedRunning(bun, moduleUrl, entry) {
|
|
357
|
+
const cache = createRequire(entry).cache;
|
|
358
|
+
// the entry has finished
|
|
359
|
+
if (BUN_MAIN in cache) return true;
|
|
360
|
+
const outer = outermostFile();
|
|
361
|
+
// a CommonJS entry: late unless on its first run
|
|
362
|
+
if (entry in cache) return outer !== undefined && outer !== entry;
|
|
363
|
+
let own = null;
|
|
364
|
+
try {
|
|
365
|
+
own = fileURLToPath(moduleUrl);
|
|
366
|
+
} catch {
|
|
367
|
+
// a CommonJS bundle has no import.meta.url
|
|
368
|
+
}
|
|
369
|
+
// react-x11 inside the entry, or loaded by code below it on the stack
|
|
370
|
+
if (own === entry || (outer !== undefined && outer !== own)) return false;
|
|
371
|
+
// reached by an import: late once the entry's body has started
|
|
372
|
+
const { imports } = new bun.Transpiler({
|
|
373
|
+
loader: SCAN_LOADER[path.extname(entry)] ?? 'jsx',
|
|
374
|
+
}).scan(fs.readFileSync(entry, 'utf8'));
|
|
375
|
+
const dir = path.dirname(entry);
|
|
376
|
+
return imports.every(
|
|
377
|
+
({ kind, path: specifier }) =>
|
|
378
|
+
kind !== 'import-statement' || bun.resolveSync(specifier, dir) in cache,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* The file of the outermost frame on this stack that is not native code,
|
|
384
|
+
* or undefined where the stack cannot be read.
|
|
385
|
+
*/
|
|
386
|
+
function outermostFile() {
|
|
387
|
+
const sites = callSites();
|
|
388
|
+
if (!sites) return undefined;
|
|
389
|
+
for (let i = sites.length - 1; i >= 0; i--) {
|
|
390
|
+
if (!sites[i].isNative()) return sites[i].getFileName() ?? undefined;
|
|
391
|
+
}
|
|
392
|
+
return undefined;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* This stack as call sites, every frame of it, or null where it cannot be
|
|
397
|
+
* read as call sites — an `Error` the app has frozen, say. Raises the stack
|
|
398
|
+
* trace limit and sets a formatter for as long as it takes, and puts back
|
|
399
|
+
* the app's own.
|
|
400
|
+
*/
|
|
401
|
+
function callSites() {
|
|
296
402
|
const { prepareStackTrace, stackTraceLimit } = Error;
|
|
297
403
|
let sites;
|
|
298
404
|
try {
|
|
299
405
|
// Reflect.set: an Error the app has frozen is not a reason to throw
|
|
300
406
|
// from its import of react-x11
|
|
301
407
|
Reflect.set(Error, 'stackTraceLimit', Infinity);
|
|
302
|
-
Reflect.set(Error, 'prepareStackTrace', (_,
|
|
408
|
+
Reflect.set(Error, 'prepareStackTrace', (_, frames) => frames);
|
|
303
409
|
const held = {};
|
|
304
410
|
Error.captureStackTrace(held);
|
|
305
411
|
sites = held.stack;
|
|
@@ -307,14 +413,7 @@ function evaluatedFromEntryPoint() {
|
|
|
307
413
|
Reflect.set(Error, 'prepareStackTrace', prepareStackTrace);
|
|
308
414
|
Reflect.set(Error, 'stackTraceLimit', stackTraceLimit);
|
|
309
415
|
}
|
|
310
|
-
|
|
311
|
-
for (const site of sites) {
|
|
312
|
-
const file = site.getFileName() ?? '';
|
|
313
|
-
if (ENTRY_POINT.has(file)) return true;
|
|
314
|
-
// the app's own code awaits this import, so it has run already
|
|
315
|
-
if (site.isAsync() && !file.startsWith('node:')) return false;
|
|
316
|
-
}
|
|
317
|
-
return false;
|
|
416
|
+
return Array.isArray(sites) ? sites : null;
|
|
318
417
|
}
|
|
319
418
|
|
|
320
419
|
/**
|
package/src/cocoa/window.js
CHANGED
|
@@ -11,6 +11,15 @@ import { CocoaDropTransport, dragSpec } from './dnd.js';
|
|
|
11
11
|
import { CocoaLayerPresenter } from './presenter.js';
|
|
12
12
|
import { CocoaPromotion } from './promotion.js';
|
|
13
13
|
|
|
14
|
+
/** The bridge verb each `_NET_WM_STATE` name maps onto (`setWmState`). */
|
|
15
|
+
const STATE_VERBS = {
|
|
16
|
+
fullscreen: 'setWindowFullScreen',
|
|
17
|
+
maximized: 'setWindowZoomed',
|
|
18
|
+
maximized_vert: 'setWindowZoomed',
|
|
19
|
+
maximized_horz: 'setWindowZoomed',
|
|
20
|
+
hidden: 'setWindowMiniaturized',
|
|
21
|
+
};
|
|
22
|
+
|
|
14
23
|
let nextWindowId = 1;
|
|
15
24
|
// The slack a frame clock allows under its interval, in ms: a timer's drift
|
|
16
25
|
// and no more (`CocoaApp._frameDue`, `_frameWait`, `nextFrameAt`). It lives
|
|
@@ -275,6 +284,10 @@ export class CocoaWindow {
|
|
|
275
284
|
// --- events --------------------------------------------------------------
|
|
276
285
|
|
|
277
286
|
on(name, fn) {
|
|
287
|
+
// what a change is measured from: the states when the first listener
|
|
288
|
+
// came, which it reads for itself with getWmStates
|
|
289
|
+
if (name === 'statechange' && !this._lastStates)
|
|
290
|
+
this._lastStates = this.getWmStatesNow();
|
|
278
291
|
let list = this._handlers.get(name);
|
|
279
292
|
if (!list) this._handlers.set(name, (list = []));
|
|
280
293
|
list.push(fn);
|
|
@@ -286,6 +299,14 @@ export class CocoaWindow {
|
|
|
286
299
|
};
|
|
287
300
|
}
|
|
288
301
|
|
|
302
|
+
/** ntk's spelling of the function `on` answers, for a listener held by
|
|
303
|
+
* the function it added (`useWindowState`'s `statechange`). */
|
|
304
|
+
off(name, fn) {
|
|
305
|
+
const list = this._handlers.get(name);
|
|
306
|
+
const at = list ? list.indexOf(fn) : -1;
|
|
307
|
+
if (at >= 0) list.splice(at, 1);
|
|
308
|
+
}
|
|
309
|
+
|
|
289
310
|
emit(name, ev) {
|
|
290
311
|
const list = this._handlers.get(name);
|
|
291
312
|
if (!list) return;
|
|
@@ -449,33 +470,95 @@ export class CocoaWindow {
|
|
|
449
470
|
setActions() {}
|
|
450
471
|
|
|
451
472
|
/**
|
|
452
|
-
* `_NET_WM_STATE` requests,
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
473
|
+
* `_NET_WM_STATE` requests, with the verb AppKit has for each:
|
|
474
|
+
*
|
|
475
|
+
* | name | verb |
|
|
476
|
+
* | --- | --- |
|
|
477
|
+
* | `fullscreen` | the green button's full-screen Space (`setWindowFullScreen`) |
|
|
478
|
+
* | `maximized`, `maximized_vert`, `maximized_horz` | the zoom to the standard frame (`setWindowZoomed`) — AppKit zooms both axes or neither |
|
|
479
|
+
* | `hidden` | to the Dock and back (`setWindowMiniaturized`) |
|
|
480
|
+
* | `demands_attention` | the Dock bounce (`requestUserAttention`), held until the state is removed or the window goes |
|
|
481
|
+
*
|
|
482
|
+
* Every other name resolves `false` — ntk's own contract for a state the
|
|
483
|
+
* server cannot honour — and so does a name whose verb a bridge older than
|
|
484
|
+
* @windowkit/appkit 0.30.0 does not have; WindowNode swallows the false.
|
|
485
|
+
* A transition is AppKit's to animate, and what happened comes back as
|
|
486
|
+
* `statechange` (`_refreshStates`).
|
|
458
487
|
*/
|
|
459
488
|
setWmState(names, action = 'add') {
|
|
460
489
|
const list = Array.isArray(names) ? names : [names];
|
|
490
|
+
const held = action === 'toggle' ? new Set(this.getWmStatesNow()) : null;
|
|
491
|
+
const wants = (name) => (held ? !held.has(name) : action !== 'remove');
|
|
461
492
|
let honoured = true;
|
|
462
493
|
for (const name of list) {
|
|
463
|
-
if (name
|
|
464
|
-
|
|
494
|
+
if (name === 'demands_attention') {
|
|
495
|
+
const holding = this._attentionRequest != null;
|
|
496
|
+
const on = action === 'toggle' ? !holding : action === 'add';
|
|
497
|
+
if (on && !holding && !this.destroyed) {
|
|
498
|
+
this._attentionRequest = this.app.requestAttention();
|
|
499
|
+
} else if (!on && holding) {
|
|
500
|
+
this.app.cancelAttention(this._attentionRequest);
|
|
501
|
+
this._attentionRequest = null;
|
|
502
|
+
}
|
|
465
503
|
continue;
|
|
466
504
|
}
|
|
467
|
-
const
|
|
468
|
-
const
|
|
469
|
-
if (
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
this.app.cancelAttention(this._attentionRequest);
|
|
473
|
-
this._attentionRequest = null;
|
|
505
|
+
const verb = STATE_VERBS[name];
|
|
506
|
+
const fn = verb && this._native[verb];
|
|
507
|
+
if (typeof fn !== 'function' || this.destroyed) {
|
|
508
|
+
honoured = false;
|
|
509
|
+
continue;
|
|
474
510
|
}
|
|
511
|
+
// a toggle of an axis reads the pair: zoomed is both or neither
|
|
512
|
+
const on =
|
|
513
|
+
held && verb === 'setWindowZoomed'
|
|
514
|
+
? !held.has('maximized_vert')
|
|
515
|
+
: wants(name);
|
|
516
|
+
fn.call(this._native, this._h, on);
|
|
475
517
|
}
|
|
476
518
|
return Promise.resolve(honoured);
|
|
477
519
|
}
|
|
478
520
|
|
|
521
|
+
/**
|
|
522
|
+
* What AppKit has done with the window, in `_NET_WM_STATE` names — the
|
|
523
|
+
* names the X11 and Wayland windows report, so `useWindowState()` and
|
|
524
|
+
* `onStatesChange` read them the same way: `fullscreen`, the zoom as
|
|
525
|
+
* `maximized_vert` + `maximized_horz`, the Dock as `hidden`, the key
|
|
526
|
+
* window as `focused`. Read from the bridge's published window state, so
|
|
527
|
+
* a worker answers without a hop; `fullscreen` there says where a
|
|
528
|
+
* transition is going from the moment it starts, which is when an app
|
|
529
|
+
* hides its chrome. A bridge older than 0.30.0 publishes only `key`.
|
|
530
|
+
*/
|
|
531
|
+
getWmStatesNow() {
|
|
532
|
+
const f = this._h != null ? this._native.getWindowFrame?.(this._h) : null;
|
|
533
|
+
if (!f) return [];
|
|
534
|
+
const out = [];
|
|
535
|
+
if (f.fullscreen) out.push('fullscreen');
|
|
536
|
+
if (f.zoomed) out.push('maximized_vert', 'maximized_horz');
|
|
537
|
+
if (f.miniaturized) out.push('hidden');
|
|
538
|
+
if (f.key) out.push('focused');
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
getWmStates() {
|
|
543
|
+
return Promise.resolve(this.getWmStatesNow());
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
/**
|
|
547
|
+
* Something that can change the states happened — a full-screen
|
|
548
|
+
* transition, the Dock, a resize (AppKit says nothing of a zoom but the
|
|
549
|
+
* resize), the key window moving — so read them again and say so if they
|
|
550
|
+
* moved. Free for a window nobody asked: no listener, no read.
|
|
551
|
+
*/
|
|
552
|
+
_refreshStates() {
|
|
553
|
+
if (this.destroyed || !this._handlers.get('statechange')?.length) return;
|
|
554
|
+
const next = this.getWmStatesNow();
|
|
555
|
+
const was = this._lastStates ?? [];
|
|
556
|
+
this._lastStates = next;
|
|
557
|
+
if (was.length === next.length && was.every((n, i) => n === next[i]))
|
|
558
|
+
return;
|
|
559
|
+
this.emit('statechange', next);
|
|
560
|
+
}
|
|
561
|
+
|
|
479
562
|
// --- drag and drop (src/cocoa/dnd.js) ------------------------------------
|
|
480
563
|
|
|
481
564
|
/**
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// CSS's `currentColor`: the colour keyword that is not a colour yet.
|
|
2
|
+
//
|
|
3
|
+
// A leaf, like decorations.js, because every layer that reads a colour asks
|
|
4
|
+
// about it — the grammar (styles.js, shorthands.js, decorations.js) to let
|
|
5
|
+
// it through, and the paint (nodes/boxpaint.js and every backend's reader
|
|
6
|
+
// of a style colour) to resolve it.
|
|
7
|
+
//
|
|
8
|
+
// It stays the keyword in `node.style` — CSS's computed value — and becomes
|
|
9
|
+
// a colour only where something is about to paint it, against the node's
|
|
10
|
+
// resolved ink (`Node.resolveColor`). Resolving it once in the style funnel
|
|
11
|
+
// instead would bake the ink into the style object, which no inherited
|
|
12
|
+
// change reaches: a `:hover { color }` on a row moves the ink of every node
|
|
13
|
+
// under it without restyling any of them.
|
|
14
|
+
|
|
15
|
+
/** `currentColor`, spelled any way CSS accepts — its keywords are ASCII
|
|
16
|
+
* case-insensitive — and with the whitespace a style value may carry. */
|
|
17
|
+
const KEYWORD = /^\s*currentcolor\s*$/i;
|
|
18
|
+
const MENTION = /currentcolor/i;
|
|
19
|
+
|
|
20
|
+
/** Whether `value` is the keyword. Asked per colour per paint, so a value
|
|
21
|
+
* too short to be it is answered without the match. */
|
|
22
|
+
export const isCurrentColor = (value) =>
|
|
23
|
+
typeof value === 'string' && value.length >= 12 && KEYWORD.test(value);
|
|
24
|
+
|
|
25
|
+
/** Whether a value made of several — a gradient, a shadow list — names the
|
|
26
|
+
* keyword anywhere in it. */
|
|
27
|
+
export const mentionsCurrentColor = (value) =>
|
|
28
|
+
typeof value === 'string' && value.length >= 12 && MENTION.test(value);
|
package/src/decorations.js
CHANGED
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
import { cssColorStraight } from 'ntk/color';
|
|
38
38
|
import { shadowReach, shadowSigma } from 'ntk/shadow-math';
|
|
39
39
|
|
|
40
|
+
import { isCurrentColor } from './currentcolor.js';
|
|
41
|
+
|
|
40
42
|
/** A number, with or without the `px` CSS wants and this vocabulary does not. */
|
|
41
43
|
const LENGTH = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:px)?$/i;
|
|
42
44
|
/** …and the same thing as a percentage, which colour stops also take. */
|
|
@@ -50,9 +52,11 @@ const HORIZONTAL = new Set(['left', 'right']);
|
|
|
50
52
|
/**
|
|
51
53
|
* Split on `,` or on whitespace, but only at the top level: a stop is
|
|
52
54
|
* `rgba(0, 0, 0, .4)` as often as it is `#333`, and every naive split on
|
|
53
|
-
* this vocabulary gets that wrong.
|
|
55
|
+
* this vocabulary gets that wrong. The shorthands split by it too
|
|
56
|
+
* (src/shorthands.js) — `border: '1px solid rgba(0, 0, 0, .1)'` is three
|
|
57
|
+
* values, not six.
|
|
54
58
|
*/
|
|
55
|
-
function splitTop(text, byComma) {
|
|
59
|
+
export function splitTop(text, byComma) {
|
|
56
60
|
const out = [];
|
|
57
61
|
let depth = 0;
|
|
58
62
|
let start = 0;
|
|
@@ -207,14 +211,16 @@ function readStop(part, value) {
|
|
|
207
211
|
return { color: stopColor(part, value), position: null };
|
|
208
212
|
}
|
|
209
213
|
|
|
210
|
-
/** A stop's colour, which RENDER is handed as written
|
|
211
|
-
*
|
|
214
|
+
/** A stop's colour, which RENDER is handed as written once a `currentColor`
|
|
215
|
+
* is resolved (`_backgroundGradient`) — and ntk throws on one it cannot
|
|
216
|
+
* parse from inside the frame, where nothing can catch it. */
|
|
212
217
|
function stopColor(color, value) {
|
|
213
218
|
if (isColorOrToken(color)) return color;
|
|
214
219
|
throw new Error(
|
|
215
220
|
`react-x11: backgroundImage ${JSON.stringify(value)} has a colour stop ` +
|
|
216
221
|
`that is not a colour ("${color}"; expected one like '#2980b9', ` +
|
|
217
|
-
"'tomato'
|
|
222
|
+
"'tomato', 'rgba(0, 0, 0, .4)' or 'currentColor', or a $token, before " +
|
|
223
|
+
'its position)',
|
|
218
224
|
);
|
|
219
225
|
}
|
|
220
226
|
|
|
@@ -223,10 +229,15 @@ function stopColor(color, value) {
|
|
|
223
229
|
* One that mentions a `$token` is let through where the style is written,
|
|
224
230
|
* since the token means nothing yet; the paint parses the value it resolves
|
|
225
231
|
* to, and a colour that is still not one drops the whole decoration there
|
|
226
|
-
* rather than throwing (see "memoized parsing" below).
|
|
232
|
+
* rather than throwing (see "memoized parsing" below). `currentColor` is
|
|
233
|
+
* the node's ink, which only the paint knows (`Node.resolveColor`).
|
|
227
234
|
*/
|
|
228
235
|
function isColorOrToken(color) {
|
|
229
|
-
return
|
|
236
|
+
return (
|
|
237
|
+
color.includes('$') ||
|
|
238
|
+
cssColorStraight(color) !== null ||
|
|
239
|
+
isCurrentColor(color)
|
|
240
|
+
);
|
|
230
241
|
}
|
|
231
242
|
|
|
232
243
|
/**
|
|
@@ -349,7 +360,8 @@ function resolveStopOffsets(stops, length) {
|
|
|
349
360
|
* renderer draws the list back to front.
|
|
350
361
|
*
|
|
351
362
|
* `color` may be null, which is CSS's `currentColor` — the node's own ink,
|
|
352
|
-
* which the caller resolves because only it knows the cascade.
|
|
363
|
+
* which the caller resolves because only it knows the cascade. The keyword
|
|
364
|
+
* written out reads as null too, so a shadow has one way to say it.
|
|
353
365
|
*
|
|
354
366
|
* Throws on `inset` and on anything unparseable; see the note at the top of
|
|
355
367
|
* this file for why a shadow that silently does the wrong thing is worse
|
|
@@ -394,7 +406,7 @@ function readShadow(part, value) {
|
|
|
394
406
|
}
|
|
395
407
|
const [dx, dy, blur = 0, spread = 0] = lengths;
|
|
396
408
|
if (blur < 0) throw bad('has a negative blur radius');
|
|
397
|
-
return { dx, dy, blur, spread, color };
|
|
409
|
+
return { dx, dy, blur, spread, color: isCurrentColor(color) ? null : color };
|
|
398
410
|
}
|
|
399
411
|
|
|
400
412
|
/**
|
package/src/editor.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Source locations and the editor that opens them: reading a stack trace
|
|
2
|
+
// into frames, telling the frames of an app's own code from React's and this
|
|
3
|
+
// renderer's, and handing a file and line to the developer's editor. Shared by
|
|
4
|
+
// click-to-component (an Alt+Click on an element) and `react-x11 dev`'s error
|
|
5
|
+
// window (a click on a frame), so the two open the same editor the same way.
|
|
6
|
+
// A leaf: nothing here reaches the renderer or React DevTools.
|
|
7
|
+
import { spawn } from 'node:child_process';
|
|
8
|
+
import { dirname, sep } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
|
|
11
|
+
export const STACK_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?\s*$/;
|
|
12
|
+
|
|
13
|
+
// The frames to walk past before the JSX call site itself: React captures
|
|
14
|
+
// the Error inside `jsxDEV`/`createElement`, and an element created by this
|
|
15
|
+
// renderer (or by node internals) is never what a click means. `SELF_DIR` is
|
|
16
|
+
// whichever copy of react-x11 is running — the repo's own `src/` for the
|
|
17
|
+
// examples and tests, an installed `node_modules/react-x11/src` for an app.
|
|
18
|
+
//
|
|
19
|
+
// Everything *else* in `node_modules` is deliberately not skipped here: a
|
|
20
|
+
// frame inside an installed package is a real call site, it is just not one
|
|
21
|
+
// in the application's source, and telling the two apart is what lets
|
|
22
|
+
// `resolveOwnedLocation` climb to the owner that is. Skipping every
|
|
23
|
+
// `node_modules` frame instead would walk straight past the library
|
|
24
|
+
// component and land on whatever application frame happens to be deeper in
|
|
25
|
+
// the render stack — usually the `render()` call at startup, which looks
|
|
26
|
+
// like an answer and isn't.
|
|
27
|
+
const REACT_RUNTIME =
|
|
28
|
+
/[\\/]node_modules[\\/](react|react-dom|react-reconciler|react-refresh|scheduler)[\\/]/;
|
|
29
|
+
const SELF_DIR = dirname(fileURLToPath(import.meta.url)) + sep;
|
|
30
|
+
|
|
31
|
+
export function isInternalFrame(file) {
|
|
32
|
+
return (
|
|
33
|
+
file.startsWith('node:') ||
|
|
34
|
+
file === '<anonymous>' ||
|
|
35
|
+
file.startsWith(SELF_DIR) ||
|
|
36
|
+
REACT_RUNTIME.test(file)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// GUI editors: navigate their registered URI scheme (same idea as
|
|
41
|
+
// https://github.com/sidorares/show-component, minus the browser) instead of
|
|
42
|
+
// spawning their CLI shim. The CLI wrapper (e.g. `cursor -g ...`) round-trips
|
|
43
|
+
// through a shell script that re-detects and re-execs the real app, which is
|
|
44
|
+
// visibly slower than the OS handing the URL straight to the already-running
|
|
45
|
+
// instance via Launch Services (macOS) / xdg-open (Linux).
|
|
46
|
+
const EDITOR_SCHEMES = {
|
|
47
|
+
cursor: 'cursor',
|
|
48
|
+
code: 'vscode',
|
|
49
|
+
vscode: 'vscode',
|
|
50
|
+
'code-insiders': 'vscode-insiders',
|
|
51
|
+
'vscode-insiders': 'vscode-insiders',
|
|
52
|
+
windsurf: 'windsurf',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Terminal editors have no URI scheme to navigate — spawn them directly.
|
|
56
|
+
const EDITOR_CLI = {
|
|
57
|
+
vim: (file, line, column) => [
|
|
58
|
+
'vim',
|
|
59
|
+
[`+call cursor(${line},${column})`, file],
|
|
60
|
+
],
|
|
61
|
+
nvim: (file, line, column) => [
|
|
62
|
+
'nvim',
|
|
63
|
+
[`+call cursor(${line},${column})`, file],
|
|
64
|
+
],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const OPEN_URI_COMMAND = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
68
|
+
|
|
69
|
+
function editorUri(scheme, file, line, column) {
|
|
70
|
+
return `${scheme}://file${encodeURI(file)}:${line}:${column}`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function openInEditor({ file, line, column }) {
|
|
74
|
+
const name = editorName();
|
|
75
|
+
let bin, args;
|
|
76
|
+
if (EDITOR_CLI[name]) {
|
|
77
|
+
[bin, args] = EDITOR_CLI[name](file, line, column);
|
|
78
|
+
} else {
|
|
79
|
+
// an unrecognized name is assumed to be a URI scheme itself, so any
|
|
80
|
+
// editor that registers one works without a matching entry above
|
|
81
|
+
const scheme = EDITOR_SCHEMES[name] || name;
|
|
82
|
+
bin = OPEN_URI_COMMAND;
|
|
83
|
+
args = [editorUri(scheme, file, line, column)];
|
|
84
|
+
}
|
|
85
|
+
// stderr is kept (rather than the whole stdio ignored) for one reason:
|
|
86
|
+
// `open`/`xdg-open` exit non-zero and print *there* when nothing claims
|
|
87
|
+
// the scheme — an editor that isn't installed, or one whose URL handler
|
|
88
|
+
// was never registered. Swallowing that is what makes click-to-component
|
|
89
|
+
// look like it only logs: the location resolves, the console line prints,
|
|
90
|
+
// and the editor silently never opens.
|
|
91
|
+
const child = spawn(bin, args, {
|
|
92
|
+
detached: true,
|
|
93
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
94
|
+
});
|
|
95
|
+
let stderr = '';
|
|
96
|
+
// ...and unref'd, so keeping it does not keep the process alive: a pipe is
|
|
97
|
+
// a ref'd handle, and an editor that outlives the app it was launched from
|
|
98
|
+
// is the normal case, not a reason to hold the event loop open.
|
|
99
|
+
child.stderr.unref();
|
|
100
|
+
child.stderr.on('data', (chunk) => {
|
|
101
|
+
stderr += chunk;
|
|
102
|
+
});
|
|
103
|
+
child.on('error', (err) => {
|
|
104
|
+
console.warn(
|
|
105
|
+
`react-x11: could not launch "${bin}" to open ${file}:${line} (${err.code ?? err.message}). ` +
|
|
106
|
+
'Set REACT_X11_EDITOR to your editor CLI (cursor, code, code-insiders, windsurf, vim, nvim).',
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
child.on('exit', (code) => {
|
|
110
|
+
if (!code) return;
|
|
111
|
+
console.warn(
|
|
112
|
+
`react-x11: opening ${file}:${line} — \`${bin} ${args.join(' ')}\` exited ${code}. ` +
|
|
113
|
+
(stderr.trim() ? `${stderr.trim()} ` : '') +
|
|
114
|
+
`Set REACT_X11_EDITOR to the editor you actually use (currently "${name}"; ` +
|
|
115
|
+
'cursor, code, code-insiders, windsurf, vim, nvim, or any registered URI scheme).',
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
child.unref();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The editor `openInEditor` opens: `REACT_X11_EDITOR`, else `cursor`. */
|
|
122
|
+
export function editorName() {
|
|
123
|
+
return process.env.REACT_X11_EDITOR || 'cursor';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A stack trace's frames, in order: where each one is, what it is called, and
|
|
128
|
+
* whether it is React's, Node's or this renderer's own (`internal`) or inside
|
|
129
|
+
* an installed package (`installed`). Lines that are not frames — the message,
|
|
130
|
+
* a `[cause]` — are not in the list.
|
|
131
|
+
*/
|
|
132
|
+
export function stackFrames(stack) {
|
|
133
|
+
const frames = [];
|
|
134
|
+
for (const text of String(stack ?? '').split('\n')) {
|
|
135
|
+
const match = text.match(STACK_FRAME);
|
|
136
|
+
if (!match) continue;
|
|
137
|
+
const [, functionName, rawFile, lineStr, columnStr] = match;
|
|
138
|
+
const file = rawFile.startsWith('file://')
|
|
139
|
+
? fileURLToPath(rawFile)
|
|
140
|
+
: rawFile;
|
|
141
|
+
frames.push({
|
|
142
|
+
functionName: functionName ?? null,
|
|
143
|
+
file,
|
|
144
|
+
line: Number(lineStr),
|
|
145
|
+
column: Number(columnStr),
|
|
146
|
+
internal: isInternalFrame(file),
|
|
147
|
+
installed: file.includes(`${sep}node_modules${sep}`),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return frames;
|
|
151
|
+
}
|
package/src/foreignnodes.js
CHANGED
|
@@ -482,12 +482,19 @@ export class ForeignNode extends Node {
|
|
|
482
482
|
_applyBackground() {
|
|
483
483
|
const wnd = this.socket?.window;
|
|
484
484
|
if (typeof wnd?.setBackgroundPixel !== 'function') return;
|
|
485
|
-
const pixel = pixelFor(this.style.backgroundColor);
|
|
485
|
+
const pixel = pixelFor(this.resolveColor(this.style.backgroundColor));
|
|
486
486
|
if (pixel === null || pixel === this._backgroundPixel) return;
|
|
487
487
|
this._backgroundPixel = pixel;
|
|
488
488
|
wnd.setBackgroundPixel(pixel);
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
+
/** …which a `currentColor` takes from the ink, and the ink can move with
|
|
492
|
+
* no commit here. */
|
|
493
|
+
_inkMoved() {
|
|
494
|
+
super._inkMoved();
|
|
495
|
+
this._applyBackground();
|
|
496
|
+
}
|
|
497
|
+
|
|
491
498
|
/** The client's window is a window of the display system over this one,
|
|
492
499
|
* so it follows the whole subtree's visibility — see
|
|
493
500
|
* `GlAreaNode._syncSurfaceVisibility`, which answers the same call. */
|