react-x11 2.48.0 → 2.50.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.
@@ -0,0 +1,368 @@
1
+ // The window `react-x11 dev` shows an error in: what was thrown, the source
2
+ // around where, and the frames of the stack, each of which opens its line in
3
+ // the editor (src/editor.js, the one click-to-component uses).
4
+ //
5
+ // Two kinds of error reach it. A **render** error takes the app's tree down —
6
+ // React unmounts a root whose render threw and nothing caught — so this
7
+ // window stands in for the app until the next save mounts it again. A
8
+ // **compile** error is a save that did not compile: the app keeps running the
9
+ // module's last version, and this window opens beside it until a save
10
+ // compiles.
11
+ //
12
+ // Everything the window says comes out of `describeError`, which is pure, so
13
+ // the tests can read it without rendering anything.
14
+ import { readFileSync } from 'node:fs';
15
+ import { relative } from 'node:path';
16
+ import React from 'react';
17
+ import { editorName, openInEditor, stackFrames } from '../editor.js';
18
+
19
+ const h = React.createElement;
20
+
21
+ /** Lines of source shown above and below the one that threw. */
22
+ const CONTEXT_ABOVE = 3;
23
+ const CONTEXT_BELOW = 2;
24
+
25
+ function firstLine(text) {
26
+ const at = text.indexOf('\n');
27
+ return at === -1 ? text : text.slice(0, at);
28
+ }
29
+
30
+ /**
31
+ * The source lines around `line` (1-based) of `file`: `{ number, text, at }`
32
+ * each, `at` marking the one that threw. Null when the file cannot be read.
33
+ */
34
+ function excerptOf(file, line) {
35
+ let source;
36
+ try {
37
+ source = readFileSync(file, 'utf8');
38
+ } catch {
39
+ return null;
40
+ }
41
+ const lines = source.split('\n');
42
+ if (line < 1 || line > lines.length) return null;
43
+ const from = Math.max(1, line - CONTEXT_ABOVE);
44
+ const to = Math.min(lines.length, line + CONTEXT_BELOW);
45
+ const excerpt = [];
46
+ for (let number = from; number <= to; number++) {
47
+ excerpt.push({
48
+ number,
49
+ // a tab is as wide as its font says, and two spaces is what an editor
50
+ // with a two-space indent shows
51
+ text: lines[number - 1].replace(/\t/g, ' ').replace(/\r$/, ''),
52
+ at: number === line,
53
+ });
54
+ }
55
+ return excerpt;
56
+ }
57
+
58
+ /**
59
+ * Everything the window says about one failure, as data.
60
+ *
61
+ * `failure` is `{ kind, error, filename?, componentStack? }`: `kind` is
62
+ * `'render'` or `'compile'`, `filename` the module that did not compile, and
63
+ * `componentStack` what React handed `onUncaughtError`.
64
+ */
65
+ export function describeError(failure, cwd = process.cwd()) {
66
+ const { kind, error, filename = null, componentStack = '' } = failure;
67
+ const rel = (file) => relative(cwd, file) || file;
68
+ const message = String(error?.message ?? error);
69
+ const name = error?.name && error.name !== 'Error' ? error.name : 'Error';
70
+
71
+ let heading;
72
+ let detail = '';
73
+ let location = null;
74
+ if (kind === 'compile') {
75
+ // babel says `<file>: <what> (<line>:<column>)` and then draws a frame;
76
+ // the window draws its own from `loc`, when there is one
77
+ let what = firstLine(message);
78
+ if (filename && what.startsWith(`${filename}: `)) {
79
+ what = what.slice(filename.length + 2);
80
+ }
81
+ what = what.replace(/ \(\d+:\d+\)$/, '');
82
+ heading = `${name}: ${what}`;
83
+ if (filename && error?.loc?.line) {
84
+ location = {
85
+ file: filename,
86
+ line: error.loc.line,
87
+ column: (error.loc.column ?? 0) + 1,
88
+ };
89
+ } else {
90
+ detail = message.slice(firstLine(message).length).replace(/^\n+/, '');
91
+ }
92
+ } else {
93
+ heading = `${name}: ${firstLine(message)}`;
94
+ detail = message.slice(firstLine(message).length).replace(/^\n+/, '');
95
+ }
96
+
97
+ // A compile error's stack is the compiler's: where in babel the parse
98
+ // gave up, which says nothing about the app.
99
+ const all = kind === 'compile' ? [] : stackFrames(error?.stack);
100
+ const shown = all.filter((frame) => !frame.internal);
101
+ if (!location) {
102
+ const own = shown.find((frame) => !frame.installed) ?? shown[0];
103
+ if (own) {
104
+ location = { file: own.file, line: own.line, column: own.column };
105
+ }
106
+ }
107
+
108
+ // React's component stack, innermost first: `at Broken (…)`. Host
109
+ // elements are in it too; the breadcrumb is the components.
110
+ const components = [];
111
+ for (const text of String(componentStack).split('\n')) {
112
+ const match = text.match(/^\s*at ([A-Z][\w$.]*)/);
113
+ if (match) components.push(match[1]);
114
+ }
115
+
116
+ return {
117
+ kind,
118
+ heading,
119
+ detail,
120
+ location: location && {
121
+ ...location,
122
+ where: `${rel(location.file)}:${location.line}:${location.column}`,
123
+ },
124
+ excerpt: location ? excerptOf(location.file, location.line) : null,
125
+ frames: shown.map((frame) => ({
126
+ label: frame.functionName ?? '(anonymous)',
127
+ where: `${rel(frame.file)}:${frame.line}:${frame.column}`,
128
+ file: frame.file,
129
+ line: frame.line,
130
+ column: frame.column,
131
+ own: !frame.installed,
132
+ })),
133
+ hidden: all.length - shown.length,
134
+ components: components.reverse(),
135
+ };
136
+ }
137
+
138
+ const mono = { fontFamily: '$monoFamily', fontSize: 12 };
139
+
140
+ /**
141
+ * A row that opens a source line: drawn pressed on the press and hovered
142
+ * under the pointer, because a click that opens another application takes a
143
+ * moment to show anything of its own.
144
+ */
145
+ function SourceLink({ target, children }) {
146
+ const open = () => openInEditor(target);
147
+ return h(
148
+ 'box',
149
+ {
150
+ focusable: true,
151
+ role: 'link',
152
+ onClick: open,
153
+ onKeyDown: (ev) => {
154
+ if (ev.key === 'Enter') open();
155
+ },
156
+ style: {
157
+ flexDirection: 'row',
158
+ gap: 10,
159
+ paddingTop: 3,
160
+ paddingBottom: 3,
161
+ paddingStart: 6,
162
+ paddingEnd: 6,
163
+ borderRadius: '$radiusSmall',
164
+ cursor: 'pointer',
165
+ ':hover': { backgroundColor: '$surfaceHover' },
166
+ ':active': { backgroundColor: '$surfaceActive' },
167
+ },
168
+ },
169
+ children,
170
+ );
171
+ }
172
+
173
+ function Excerpt({ excerpt, column }) {
174
+ const width = String(excerpt[excerpt.length - 1].number).length;
175
+ const rows = [];
176
+ for (const { number, text, at } of excerpt) {
177
+ rows.push(
178
+ h(
179
+ 'box',
180
+ { key: number, style: { flexDirection: 'row' } },
181
+ h(
182
+ 'text',
183
+ {
184
+ style: {
185
+ ...mono,
186
+ color: at ? '$danger' : '$textMuted',
187
+ textWrap: 'nowrap',
188
+ },
189
+ },
190
+ `${at ? '>' : ' '} ${String(number).padStart(width)} │ `,
191
+ ),
192
+ h(
193
+ 'text',
194
+ {
195
+ style: {
196
+ ...mono,
197
+ color: at ? '$text' : '$textMuted',
198
+ textWrap: 'nowrap',
199
+ flexShrink: 1,
200
+ },
201
+ },
202
+ text || ' ',
203
+ ),
204
+ ),
205
+ );
206
+ if (at && column > 0) {
207
+ rows.push(
208
+ h(
209
+ 'text',
210
+ {
211
+ key: 'caret',
212
+ style: { ...mono, color: '$danger', textWrap: 'nowrap' },
213
+ },
214
+ `${' '.repeat(width + 5 + column - 1)}^`,
215
+ ),
216
+ );
217
+ }
218
+ }
219
+ return h(
220
+ 'box',
221
+ {
222
+ style: {
223
+ backgroundColor: '$surfaceHover',
224
+ borderRadius: '$radius',
225
+ paddingTop: 10,
226
+ paddingBottom: 10,
227
+ paddingStart: 12,
228
+ paddingEnd: 12,
229
+ overflow: 'hidden',
230
+ },
231
+ },
232
+ rows,
233
+ );
234
+ }
235
+
236
+ /**
237
+ * The window itself. `onCloseRequest` is the host's to give: without one,
238
+ * closing the only window there is ends the app, as closing an app's primary
239
+ * window does.
240
+ */
241
+ export function ErrorWindow({ failure, title, onCloseRequest }) {
242
+ const info = describeError(failure);
243
+ const muted = { color: '$textMuted', fontSize: 12 };
244
+ const editor = editorName();
245
+ const children = [
246
+ h(
247
+ 'text',
248
+ { key: 'kind', style: { ...muted, fontWeight: 600 } },
249
+ info.kind === 'compile'
250
+ ? 'This save did not compile. The app keeps running the last version that did.'
251
+ : 'The app threw while rendering, and React took its tree down.',
252
+ ),
253
+ h(
254
+ 'text',
255
+ {
256
+ key: 'heading',
257
+ style: { color: '$danger', fontSize: 16, fontWeight: 600 },
258
+ },
259
+ info.heading,
260
+ ),
261
+ ];
262
+ if (info.components.length > 0) {
263
+ children.push(
264
+ h(
265
+ 'text',
266
+ { key: 'components', style: muted },
267
+ info.components.join(' › '),
268
+ ),
269
+ );
270
+ }
271
+ if (info.detail) {
272
+ children.push(
273
+ h(
274
+ 'text',
275
+ { key: 'detail', style: { ...mono, color: '$text' } },
276
+ info.detail,
277
+ ),
278
+ );
279
+ }
280
+ if (info.location) {
281
+ children.push(
282
+ h(
283
+ 'box',
284
+ { key: 'source', style: { gap: 6 } },
285
+ h(
286
+ SourceLink,
287
+ { target: info.location },
288
+ h(
289
+ 'text',
290
+ { style: { ...mono, color: '$link' } },
291
+ info.location.where,
292
+ ),
293
+ ),
294
+ info.excerpt
295
+ ? h(Excerpt, { excerpt: info.excerpt, column: info.location.column })
296
+ : null,
297
+ ),
298
+ );
299
+ }
300
+ if (info.frames.length > 0) {
301
+ children.push(
302
+ h(
303
+ 'box',
304
+ { key: 'frames', style: { gap: 1 } },
305
+ h(
306
+ 'text',
307
+ { style: { ...muted, fontWeight: 600, marginBottom: 4 } },
308
+ 'Stack',
309
+ ),
310
+ info.frames.map((frame, i) =>
311
+ h(
312
+ SourceLink,
313
+ { key: i, target: frame },
314
+ h(
315
+ 'text',
316
+ {
317
+ style: {
318
+ ...mono,
319
+ color: frame.own ? '$text' : '$textMuted',
320
+ fontWeight: frame.own ? 600 : 400,
321
+ },
322
+ },
323
+ frame.label,
324
+ ),
325
+ h(
326
+ 'text',
327
+ { style: { ...mono, color: '$textMuted', flexShrink: 1 } },
328
+ frame.where,
329
+ ),
330
+ ),
331
+ ),
332
+ info.hidden > 0
333
+ ? h(
334
+ 'text',
335
+ { style: { ...muted, marginTop: 4, marginLeft: 6 } },
336
+ `${info.hidden} more ${info.hidden === 1 ? 'frame' : 'frames'} in React, Node and react-x11, not shown.`,
337
+ )
338
+ : null,
339
+ ),
340
+ );
341
+ }
342
+ children.push(
343
+ h(
344
+ 'text',
345
+ { key: 'footer', style: { ...muted, marginTop: 4 } },
346
+ (info.kind === 'compile'
347
+ ? 'Fix it and save: this window goes once the file compiles. '
348
+ : 'Fix it and save: the app is mounted again on the next reload. ') +
349
+ `Click a line to open it in ${editor}` +
350
+ (process.env.REACT_X11_EDITOR
351
+ ? '.'
352
+ : '; REACT_X11_EDITOR names another editor.'),
353
+ ),
354
+ );
355
+
356
+ return h(
357
+ 'window',
358
+ { title, width: 760, minHeight: 240, onCloseRequest },
359
+ h(
360
+ 'box',
361
+ {
362
+ selectable: true,
363
+ style: { flexGrow: 1, padding: 20, gap: 14, overflow: 'scroll' },
364
+ },
365
+ children,
366
+ ),
367
+ );
368
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // react-x11's command line, the `bin` of the package. One command so far:
3
+ //
4
+ // react-x11 dev <file> [args…] hot reload with nothing to set up (./dev.js)
5
+ //
6
+ // Arguments before the file belong to the command, and everything after it
7
+ // to the app, which sees them in process.argv as `node <file> …args` would
8
+ // have handed them over.
9
+ import { readFileSync } from 'node:fs';
10
+ import { DevError, runDev } from './dev.js';
11
+
12
+ const USAGE = `Usage: react-x11 dev <file> [args…]
13
+
14
+ Commands:
15
+ dev <file> Run <file> with hot reload: saving it, or a .jsx module it
16
+ imports, updates the running app in place. Its default
17
+ export is mounted, inside a window of its own when it
18
+ renders none; a file that mounts its own root runs as it is.
19
+ Arguments after the file are the app's.
20
+
21
+ Options:
22
+ -h, --help Show this help.
23
+ -v, --version Print react-x11's version.
24
+ `;
25
+
26
+ function version() {
27
+ const manifest = new URL('../../package.json', import.meta.url);
28
+ return JSON.parse(readFileSync(manifest, 'utf8')).version;
29
+ }
30
+
31
+ /** An exit code to finish with, or undefined while the app runs. */
32
+ async function main([command, ...rest]) {
33
+ if (command === '-h' || command === '--help' || command === 'help') {
34
+ process.stdout.write(USAGE);
35
+ return 0;
36
+ }
37
+ if (command === '-v' || command === '--version') {
38
+ process.stdout.write(`${version()}\n`);
39
+ return 0;
40
+ }
41
+ if (command === undefined) {
42
+ process.stderr.write(USAGE);
43
+ return 2;
44
+ }
45
+ if (command !== 'dev') {
46
+ process.stderr.write(`react-x11: no command "${command}".\n\n${USAGE}`);
47
+ return 2;
48
+ }
49
+ const at = rest.findIndex((arg) => !arg.startsWith('-'));
50
+ const own = at === -1 ? rest : rest.slice(0, at);
51
+ if (own.includes('-h') || own.includes('--help')) {
52
+ process.stdout.write(USAGE);
53
+ return 0;
54
+ }
55
+ if (own.length > 0) {
56
+ process.stderr.write(
57
+ `react-x11 dev: no option ${own[0]} — options after the file are ` +
58
+ `the app's.\n\n${USAGE}`,
59
+ );
60
+ return 2;
61
+ }
62
+ if (at === -1) {
63
+ process.stderr.write(`react-x11 dev: which file?\n\n${USAGE}`);
64
+ return 2;
65
+ }
66
+ await runDev(rest[at], { args: rest.slice(at + 1) });
67
+ return undefined;
68
+ }
69
+
70
+ try {
71
+ const code = await main(process.argv.slice(2));
72
+ if (code !== undefined) process.exitCode = code;
73
+ } catch (err) {
74
+ // A DevError is a sentence about the setup, and its stack is ours; any
75
+ // other throw came out of the app's own module, and its stack is the
76
+ // useful part.
77
+ console.error(
78
+ err instanceof DevError ? `react-x11 dev: ${err.message}` : err,
79
+ );
80
+ process.exitCode = 1;
81
+ }
package/src/cocoa/app.js CHANGED
@@ -178,7 +178,17 @@ export class CocoaApp {
178
178
  // policy goes first, through `initApp`'s option (bridge >= 0.5), and
179
179
  // only when the root asked for one: a bridge without the option, or a
180
180
  // fake, is left alone.
181
- const policy = options.cocoa?.activationPolicy;
181
+ //
182
+ // A `<Frame>` pane asks for one without being told: 'prohibited'. Its
183
+ // pixels are the host's to show, it makes no NSWindow (`createWindow`)
184
+ // and it runs no NSApplication, so nothing of it may be an app the user
185
+ // sees — and launched Regular, the bridge's default, every pane was a
186
+ // `node` tile of its own in the Dock and in ⌘-Tab, one per tab of a
187
+ // browser. Prohibited rather than accessory because a pane must not be
188
+ // activated either: the host window has the focus and forwards it.
189
+ const pane = options.pane ?? process.env.REACT_X11_FRAME === '1';
190
+ const policy =
191
+ options.cocoa?.activationPolicy ?? (pane ? 'prohibited' : undefined);
182
192
  // Under the launcher (src/cocoa/main.js) the app launched before this
183
193
  // code ran, so the policy is a switch after the fact, and a Regular
184
194
  // launch has already registered its Dock tile. The bridge publishes
@@ -257,7 +267,6 @@ export class CocoaApp {
257
267
  // here then (src/cocoa/paneplayer.js); one that does not leaves a pane
258
268
  // refusing `src`, its poster shown and `onError` told.
259
269
  this._players = new Map();
260
- const pane = options.pane ?? process.env.REACT_X11_FRAME === '1';
261
270
  if (canPlay(native) && !pane) {
262
271
  this.createPlayer = (src, options) => new CocoaPlayer(this, src, options);
263
272
  }
@@ -388,7 +397,7 @@ export class CocoaApp {
388
397
 
389
398
  // Frame-pane mode: this process renders a pane whose pixels a host
390
399
  // composites (REACT_X11_FRAME is what the Frame host sets on the fork).
391
- this._paneMode = options.pane ?? process.env.REACT_X11_FRAME === '1';
400
+ this._paneMode = pane;
392
401
  this._paneSend = null;
393
402
  // the pane's own window, the one the host lays out and forwards to
394
403
  this._paneWindow = null;
@@ -502,7 +502,9 @@ export function propBoxProps(node, app, scale, parentOrigin, order) {
502
502
  const style = node.style ?? {};
503
503
  const border = typeof style.borderWidth === 'number' ? style.borderWidth : 0;
504
504
  const colour = (value) =>
505
- value ? (app._parseColor(String(value)) ?? [0, 0, 0, 0]) : [0, 0, 0, 0];
505
+ value
506
+ ? (app._parseColor(String(node.resolveColor(value))) ?? [0, 0, 0, 0])
507
+ : [0, 0, 0, 0];
506
508
  return {
507
509
  frame: [
508
510
  (abs.x - parentOrigin.x) / scale,
@@ -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
  }