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.
@@ -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?.borderRadius > 0)
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
- typeof parent.style?.borderRadius === 'number' &&
876
- parent.style.borderRadius > 0 &&
877
- !containsRect(insetRect(parent.abs, parent.style.borderRadius), bounds)
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.borderRadius - border);
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?.borderRadius;
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
  }
@@ -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
- node: bun ? null : Number(process.versions.node.split('.')[0]),
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 1 there from the first line; what tells in Bun is the entry's
282
- * record in the module cache, which is there once the entry has been
283
- * evaluated and absent while its imports are (measured, Bun 1.4).
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 Boolean(createRequire(moduleUrl).cache?.[entry]?.loaded);
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', (_, callSites) => callSites);
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
- if (!Array.isArray(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;
416
+ return Array.isArray(sites) ? sites : null;
318
417
  }
319
418
 
320
419
  /**
@@ -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, as far as this backend has verbs for them:
453
- * `demands_attention` is the Dock bounce (`requestUserAttention`), held
454
- * until the state is removed or the window goes. Every other name resolves
455
- * `false` — ntk's own contract for a state the server cannot honour —
456
- * because the bridge has no zoom/miniaturize/fullscreen verbs yet
457
- * (windowkit/appkit#15 scoped them out); WindowNode swallows the false.
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 !== 'demands_attention') {
464
- honoured = false;
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 held = this._attentionRequest != null;
468
- const wants = action === 'add' || (action === 'toggle' && !held);
469
- if (wants && !held && !this.destroyed) {
470
- this._attentionRequest = this.app.requestAttention();
471
- } else if (!wants && held) {
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);
@@ -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 — and ntk throws on
211
- * one it cannot parse from inside the frame, where nothing can catch it. */
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' or 'rgba(0, 0, 0, .4)', or a $token, before its position)",
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 color.includes('$') || cssColorStraight(color) !== null;
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
+ }
@@ -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. */