react-x11 2.49.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.
- package/package.json +3 -3
- package/src/ClickToComponent.js +11 -132
- package/src/cli/dev.js +94 -19
- package/src/cli/errorwindow.js +368 -0
- package/src/cocoa/presenter.js +3 -1
- package/src/cocoa/promotion.js +10 -11
- package/src/cocoa/relaunch.js +110 -13
- 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/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/style.d.ts +163 -39
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. */
|
package/src/gloverlay.js
CHANGED
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
import { cssColorStraight } from 'ntk/color';
|
|
87
87
|
|
|
88
88
|
import { FULL_DAMAGE, addDamageRect } from './nodes/damage.js';
|
|
89
|
+
import { radiusOf } from './styles.js';
|
|
89
90
|
import {
|
|
90
91
|
innerPixels,
|
|
91
92
|
intersectRects,
|
|
@@ -1200,7 +1201,7 @@ export class GlOverlay {
|
|
|
1200
1201
|
}
|
|
1201
1202
|
const area = this.area;
|
|
1202
1203
|
// a rounded surface clips its children to an arc, and the arc stays put
|
|
1203
|
-
if ((area.style
|
|
1204
|
+
if (radiusOf(area.style, area.abs) > 0) return false;
|
|
1204
1205
|
// the whole pixels of the surface's box: a fractional edge is an
|
|
1205
1206
|
// antialiased clip, and that does not move either
|
|
1206
1207
|
const box = innerPixels(area.abs);
|
package/src/node.d.ts
CHANGED
|
@@ -624,6 +624,11 @@ export declare class Node {
|
|
|
624
624
|
* with none of its own is set in. An element that draws text inherits
|
|
625
625
|
* exactly what `<text>` inherits by asking. */
|
|
626
626
|
resolvedTextStyle(): TextStyle;
|
|
627
|
+
/** A colour from this node's style as one a context can paint: the value
|
|
628
|
+
* itself, or — for `currentColor` — the node's ink, the colour
|
|
629
|
+
* `resolvedTextStyle()` resolves. Everything core paints from a style
|
|
630
|
+
* colour asks here; an element painting one of its own should too. */
|
|
631
|
+
resolveColor<T>(value: T): T | string;
|
|
627
632
|
/** The flattened `style` prop with the active state blocks overlaid.
|
|
628
633
|
* Everything that paints or lays out reads this, never `props`. */
|
|
629
634
|
readonly style: Style;
|
package/src/nodes/animation.js
CHANGED
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
transitionFor,
|
|
12
12
|
interpolate,
|
|
13
13
|
ease,
|
|
14
|
+
isColor,
|
|
14
15
|
isLayoutProp,
|
|
15
16
|
TEXT_LAYOUT_PROPS,
|
|
16
17
|
} from '../styles.js';
|
|
18
|
+
import { isCurrentColor } from '../currentcolor.js';
|
|
17
19
|
import { GRID_CONTAINER_PROPS, GRID_ITEM_PROPS } from '../grid.js';
|
|
18
20
|
import { isPlaced } from '../layouts.js';
|
|
19
21
|
import { desktopSettings, watchDesktopSettings } from '../desktopsettings.js';
|
|
@@ -85,11 +87,16 @@ export class NodeAnimation {
|
|
|
85
87
|
// transitions start on later changes, which is CSS's rule too.
|
|
86
88
|
if (this._placed) {
|
|
87
89
|
for (const prop of Object.keys(target)) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
+
let to = target[prop];
|
|
91
|
+
let from = displayed[prop];
|
|
90
92
|
if (from === to || from === undefined) continue;
|
|
91
93
|
const duration = transitionFor(target, prop);
|
|
92
94
|
if (duration <= 0) continue;
|
|
95
|
+
if (isCurrentColor(from) || isCurrentColor(to)) {
|
|
96
|
+
from = this._swapInk(prop, from, displayed);
|
|
97
|
+
to = this._swapInk(prop, to, target);
|
|
98
|
+
if (from === to) continue;
|
|
99
|
+
}
|
|
93
100
|
if (interpolate(from, to, 0.5) === null) continue; // no midpoint: snap
|
|
94
101
|
const entry = {
|
|
95
102
|
from,
|
|
@@ -260,6 +267,27 @@ export class NodeAnimation {
|
|
|
260
267
|
return this.style;
|
|
261
268
|
}
|
|
262
269
|
|
|
270
|
+
/**
|
|
271
|
+
* A `currentColor` end of a transition as the colour it is at the swap:
|
|
272
|
+
* the ink `style` gives this node, or on `color` itself the ink it
|
|
273
|
+
* inherits. The keyword has no midpoint of its own, so the transition
|
|
274
|
+
* runs between the two colours as they are now — and lands on the style,
|
|
275
|
+
* where the keyword follows the ink again. An ink that moves mid-flight
|
|
276
|
+
* is caught up with at the end, not chased: a `color` that should carry
|
|
277
|
+
* a border with it is the property to transition.
|
|
278
|
+
*/
|
|
279
|
+
_swapInk(prop, value, style) {
|
|
280
|
+
if (!isCurrentColor(value)) return value;
|
|
281
|
+
const inherited = this.inheritedTextStyle.color;
|
|
282
|
+
if (prop === 'color') return inherited;
|
|
283
|
+
// the displayed style's ink is cached; the target's is not yet
|
|
284
|
+
return style === this.style
|
|
285
|
+
? this.resolvedTextStyle().color
|
|
286
|
+
: isColor(style.color)
|
|
287
|
+
? style.color
|
|
288
|
+
: inherited;
|
|
289
|
+
}
|
|
290
|
+
|
|
263
291
|
_animatedValues() {
|
|
264
292
|
const values = {};
|
|
265
293
|
for (const [prop, a] of this._anim) {
|
|
@@ -388,7 +416,7 @@ export class NodeAnimation {
|
|
|
388
416
|
const specs =
|
|
389
417
|
target.animation == null
|
|
390
418
|
? null
|
|
391
|
-
: animationsOf(target, 'a style', this.scale);
|
|
419
|
+
: animationsOf(target, 'a style', this.scale, this._units);
|
|
392
420
|
if (!specs && !this._loops) return false;
|
|
393
421
|
this._loops = specs;
|
|
394
422
|
if (!specs) this.root?._forgetLoopNode(this);
|
package/src/nodes/boxpaint.js
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
import {
|
|
6
6
|
DEFAULT_FOCUS_RING,
|
|
7
7
|
isColor,
|
|
8
|
+
radiusOf,
|
|
8
9
|
resolveBorderWidths,
|
|
9
10
|
resolveBorderColors,
|
|
10
11
|
} from '../styles.js';
|
|
12
|
+
import { isCurrentColor } from '../currentcolor.js';
|
|
11
13
|
import {
|
|
12
14
|
blurKernel,
|
|
13
15
|
gradientSpec,
|
|
@@ -61,6 +63,19 @@ let warnedSideRadius = false;
|
|
|
61
63
|
|
|
62
64
|
/** Box decorations, installed onto `Node.prototype` by node.js. */
|
|
63
65
|
export class NodeBoxPaint {
|
|
66
|
+
/**
|
|
67
|
+
* A colour from this node's style as a colour a context can paint: the
|
|
68
|
+
* value itself, or — for CSS's `currentColor` — this node's resolved ink,
|
|
69
|
+
* its own `color` over what it inherits. Everything that paints a style
|
|
70
|
+
* colour asks here, on every backend, so the keyword means one thing
|
|
71
|
+
* wherever it is written; a registered element painting a colour of its
|
|
72
|
+
* own style asks the same. A node whose style paints in the keyword
|
|
73
|
+
* repaints when its ink moves (`_inkMoved`).
|
|
74
|
+
*/
|
|
75
|
+
resolveColor(value) {
|
|
76
|
+
return isCurrentColor(value) ? this.resolvedTextStyle().color : value;
|
|
77
|
+
}
|
|
78
|
+
|
|
64
79
|
/**
|
|
65
80
|
* The focus ring this node would draw: its own `outline*` style, falling
|
|
66
81
|
* back to the theme's focus-ring tokens for anything it leaves out. Null
|
|
@@ -80,7 +95,9 @@ export class NodeBoxPaint {
|
|
|
80
95
|
if (!(resolved > 0)) return null;
|
|
81
96
|
return {
|
|
82
97
|
width: resolved,
|
|
83
|
-
color:
|
|
98
|
+
color: this.resolveColor(
|
|
99
|
+
style.outlineColor ?? theme?.focusRing ?? DEFAULT_FOCUS_RING.color,
|
|
100
|
+
),
|
|
84
101
|
offset:
|
|
85
102
|
style.outlineOffset ??
|
|
86
103
|
theme?.focusRingOffset ??
|
|
@@ -138,7 +155,7 @@ export class NodeBoxPaint {
|
|
|
138
155
|
// stroked centred on the path, like the border, so half the width sits
|
|
139
156
|
// inside the offset gap and half outside it
|
|
140
157
|
const grow = offset + width / 2;
|
|
141
|
-
const radius = this.style.
|
|
158
|
+
const radius = radiusOf(this.style, this.abs);
|
|
142
159
|
ctx.strokeStyle = outline.color;
|
|
143
160
|
ctx.lineWidth = width;
|
|
144
161
|
ctx.beginPath();
|
|
@@ -162,7 +179,8 @@ export class NodeBoxPaint {
|
|
|
162
179
|
// under an element that covers this pass with opaque pixels, this fill
|
|
163
180
|
// is never seen (`WindowNode._coverFor`)
|
|
164
181
|
if (this.root?._coverChain?.has(this)) return;
|
|
165
|
-
const
|
|
182
|
+
const backgroundColor = this.resolveColor(this.style.backgroundColor);
|
|
183
|
+
const borderRadius = radiusOf(this.style, this.abs);
|
|
166
184
|
const fill = (style) => {
|
|
167
185
|
ctx.fillStyle = style;
|
|
168
186
|
if (borderRadius > 0) {
|
|
@@ -202,7 +220,11 @@ export class NodeBoxPaint {
|
|
|
202
220
|
const spec = gradientSpec(value, this.scale);
|
|
203
221
|
if (!spec) return null;
|
|
204
222
|
const { x, y, width, height } = rect;
|
|
205
|
-
|
|
223
|
+
// a stop in `currentColor` is the ink, which can move under a value
|
|
224
|
+
// that did not, so the ink is part of what the gradient is keyed by
|
|
225
|
+
const inked = spec.stops.some((stop) => isCurrentColor(stop.color));
|
|
226
|
+
const ink = inked ? this.resolvedTextStyle().color : '';
|
|
227
|
+
const key = `${value}|${x},${y},${width},${height}|${ink}`;
|
|
206
228
|
if (this._gradient?.key === key) return this._gradient.value;
|
|
207
229
|
const line = linearGradientGeometry(spec, rect);
|
|
208
230
|
if (!line) return null;
|
|
@@ -213,7 +235,7 @@ export class NodeBoxPaint {
|
|
|
213
235
|
line.y1,
|
|
214
236
|
);
|
|
215
237
|
for (const [offset, color] of line.stops)
|
|
216
|
-
gradient.addColorStop(offset, color);
|
|
238
|
+
gradient.addColorStop(offset, inked ? this.resolveColor(color) : color);
|
|
217
239
|
this._gradient = { key, value: gradient };
|
|
218
240
|
return gradient;
|
|
219
241
|
}
|
|
@@ -235,7 +257,7 @@ export class NodeBoxPaint {
|
|
|
235
257
|
if (!value || value === 'none') return;
|
|
236
258
|
const shadows = shadowSpecs(value, this.scale);
|
|
237
259
|
if (!shadows?.length) return;
|
|
238
|
-
const radius = this.style.
|
|
260
|
+
const radius = radiusOf(this.style, this.abs);
|
|
239
261
|
for (let i = shadows.length - 1; i >= 0; i--) {
|
|
240
262
|
const shadow = shadows[i];
|
|
241
263
|
// CSS's `currentColor`: this node's *resolved* ink, its own `color`
|
|
@@ -404,11 +426,15 @@ export class NodeBoxPaint {
|
|
|
404
426
|
}
|
|
405
427
|
|
|
406
428
|
_paintBorder(ctx) {
|
|
407
|
-
const
|
|
429
|
+
const borderRadius = radiusOf(this.style, this.abs);
|
|
408
430
|
// Both through the node's resolved direction: a `borderStartWidth` lays
|
|
409
431
|
// out on one side and has to paint on the same one.
|
|
410
432
|
const w = resolveBorderWidths(this.style, this.direction);
|
|
411
433
|
const colors = resolveBorderColors(this.style, this.direction);
|
|
434
|
+
colors.top = this.resolveColor(colors.top);
|
|
435
|
+
colors.right = this.resolveColor(colors.right);
|
|
436
|
+
colors.bottom = this.resolveColor(colors.bottom);
|
|
437
|
+
colors.left = this.resolveColor(colors.left);
|
|
412
438
|
const uniform =
|
|
413
439
|
w.top === w.right &&
|
|
414
440
|
w.top === w.bottom &&
|
|
@@ -469,7 +495,7 @@ export class NodeBoxPaint {
|
|
|
469
495
|
* strokes halfway.
|
|
470
496
|
*/
|
|
471
497
|
_paintBorderSides(ctx, w, colors) {
|
|
472
|
-
if (DEV && (this.style.
|
|
498
|
+
if (DEV && radiusOf(this.style, this.abs) > 0 && !warnedSideRadius) {
|
|
473
499
|
warnedSideRadius = true;
|
|
474
500
|
console.warn(
|
|
475
501
|
'react-x11: borderRadius needs uniform borders — same width and ' +
|
package/src/nodes/cascade.js
CHANGED
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
import {
|
|
6
6
|
applyLayoutStyle,
|
|
7
7
|
isColor,
|
|
8
|
+
paintsCurrentColor,
|
|
8
9
|
textStyleFrom,
|
|
9
10
|
DEFAULT_TEXT_STYLE,
|
|
10
11
|
localTextStyleChanged,
|
|
11
12
|
resolvedTextDelta,
|
|
12
13
|
TEXT_REMEASURE,
|
|
13
14
|
} from '../styles.js';
|
|
15
|
+
import { UNIT_EM, UNIT_REM } from '../shorthands.js';
|
|
14
16
|
import { Yoga } from '../yoga.js';
|
|
15
17
|
import { scaleOf } from '../scale.js';
|
|
16
18
|
import { baseTheme } from '../palette.js';
|
|
@@ -372,8 +374,22 @@ export class NodeCascade {
|
|
|
372
374
|
const before = this._resolvedText;
|
|
373
375
|
if (before === undefined) return 0;
|
|
374
376
|
this._resolvedText = undefined;
|
|
375
|
-
|
|
376
|
-
|
|
377
|
+
// A length in `em` is a length in the font size this node inherits,
|
|
378
|
+
// which is what this walk exists because of — so a node that wrote one
|
|
379
|
+
// resolves its style again here, before its own text is, since a
|
|
380
|
+
// `fontSize: '1.5em'` is part of what that text is set in.
|
|
381
|
+
if (this._emMoved()) this._unitsMoved();
|
|
382
|
+
const after = this.resolvedTextStyle();
|
|
383
|
+
const cost = resolvedTextDelta(before, after);
|
|
384
|
+
if (cost !== 0) {
|
|
385
|
+
this._textStyleMoved(cost);
|
|
386
|
+
// A node painting in `currentColor` resolved its ink to do it, so the
|
|
387
|
+
// walk reaches it — and its own style says nothing about the change,
|
|
388
|
+
// which was written on an ancestor or in its own `color`.
|
|
389
|
+
if (before.color !== after.color && paintsCurrentColor(this.style)) {
|
|
390
|
+
this._inkMoved();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
377
393
|
return cost;
|
|
378
394
|
}
|
|
379
395
|
|
|
@@ -405,6 +421,19 @@ export class NodeCascade {
|
|
|
405
421
|
}
|
|
406
422
|
}
|
|
407
423
|
|
|
424
|
+
/**
|
|
425
|
+
* The ink moved under a node that paints something in it — a border, a
|
|
426
|
+
* fill, a gradient stop or a shadow in `currentColor`. A repaint of the
|
|
427
|
+
* node, bounded to it, and a box needs it although it draws no text
|
|
428
|
+
* (`BoxNode._textStyleMoved`): the ancestor whose `color` moved claimed
|
|
429
|
+
* its own box, which covers this one on a surface but names no node to a
|
|
430
|
+
* retained presenter re-rastering by node, and no other window to a
|
|
431
|
+
* `<popup>` written under it.
|
|
432
|
+
*/
|
|
433
|
+
_inkMoved() {
|
|
434
|
+
this.root?.invalidate(false, this, 'ink');
|
|
435
|
+
}
|
|
436
|
+
|
|
408
437
|
/**
|
|
409
438
|
* Whether this node's ancestry is complete, so a `$token` that does not
|
|
410
439
|
* resolve is a mistake rather than a node that has not been placed yet.
|
|
@@ -508,7 +537,9 @@ export class NodeCascade {
|
|
|
508
537
|
// and the server's copy of that colour has to follow with it — otherwise
|
|
509
538
|
// the next resize fills the new area in the old scheme.
|
|
510
539
|
if (this.isWindow) this._syncWindowBackground();
|
|
511
|
-
|
|
540
|
+
// A relative length reads the palette too: `rem` is its `fontSize`,
|
|
541
|
+
// and `em` is what that size became by the time it reached this node.
|
|
542
|
+
if (this._usesTokens || this._unitDeps & (UNIT_REM | UNIT_EM)) {
|
|
512
543
|
const before = this.style;
|
|
513
544
|
this._syncStyle(this.props, mounting);
|
|
514
545
|
// a token change reaches the node without React re-rendering it, so
|
|
@@ -516,6 +547,15 @@ export class NodeCascade {
|
|
|
516
547
|
if (localTextStyleChanged(this.style, before)) {
|
|
517
548
|
this._textContentChanged();
|
|
518
549
|
}
|
|
550
|
+
// …and so does the layout half. A token in a layout value —
|
|
551
|
+
// `padding: '$gutter'` — resolves right here, on attach or on a
|
|
552
|
+
// palette swap, and yoga was last handed the style the constructor
|
|
553
|
+
// had, with its tokens stripped. Same tail as `_sizeQueriesChanged`
|
|
554
|
+
// and `_rescaled`; without it a themed gutter reached `this.style`
|
|
555
|
+
// and the paint, and the layout kept the un-themed value.
|
|
556
|
+
if (this.yoga && this.style !== before) {
|
|
557
|
+
applyLayoutStyle(this.yoga, this.style, before);
|
|
558
|
+
}
|
|
519
559
|
if (!mounting) this.root?.invalidate(true, null, 'theme');
|
|
520
560
|
}
|
|
521
561
|
// The palette is the floor under the cascade, so a theme swap moves the
|
|
@@ -624,4 +664,11 @@ export class WindowCascade {
|
|
|
624
664
|
/** Like a `<box>`: a window fills and never letters, so it is the top of
|
|
625
665
|
* the cascade rather than a reader of it. */
|
|
626
666
|
_textStyleMoved() {}
|
|
667
|
+
|
|
668
|
+
/** …except a fill in `currentColor`, which the server keeps a copy of for
|
|
669
|
+
* what a resize exposes. */
|
|
670
|
+
_inkMoved() {
|
|
671
|
+
this._syncWindowBackground();
|
|
672
|
+
this.invalidate(false, null, 'ink');
|
|
673
|
+
}
|
|
627
674
|
}
|
package/src/nodes/damage.js
CHANGED
|
@@ -86,8 +86,10 @@ export const INVALIDATE_REASONS = new Set([
|
|
|
86
86
|
'shadow', // a boxShadow got smaller: where it *was* still owes a repaint
|
|
87
87
|
'outline', // …and the same for an outline a style swap took away
|
|
88
88
|
'theme', // a theme/token change restyled a subtree
|
|
89
|
+
'ink', // the ink under a currentColor moved: its `color` or an ancestor's
|
|
89
90
|
'direction', // the reading direction moved: sides, glyph order, bar edge
|
|
90
91
|
'scale', // a `scale` prop zoomed a subtree: every length in it moved
|
|
92
|
+
'units', // a font size an `em` length read moved, and the style with it
|
|
91
93
|
'animation', // a transition frame
|
|
92
94
|
'scroll', // scrollTo/scrollBy/scrollIntoView, textarea/textinput panning
|
|
93
95
|
'text', // text content, input value or caret editing
|
package/src/nodes/layout.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { Yoga } from '../yoga.js';
|
|
6
6
|
import { callHandler } from '../errors.js';
|
|
7
7
|
import { DAMAGE_SLOP, layoutDiff } from './damage.js';
|
|
8
|
-
import { resolveBorderWidths } from '../styles.js';
|
|
8
|
+
import { radiusOf, resolveBorderWidths } from '../styles.js';
|
|
9
9
|
import { insetRect, intersectRects, outerPixels, outside } from './rects.js';
|
|
10
10
|
import { DEV } from './util.js';
|
|
11
11
|
|
|
@@ -612,25 +612,17 @@ export class NodeLayout {
|
|
|
612
612
|
// (`roundedCorners`), so a resize that moves that moves all four
|
|
613
613
|
// corners — and their outline and shadow with them — and the ones at the
|
|
614
614
|
// edges that held are in no band along the edges that moved.
|
|
615
|
-
|
|
615
|
+
// A percentage radius moves with the box for the same reason: it is a
|
|
616
|
+
// share of the shorter side (`radiusOf`), read off each rect.
|
|
616
617
|
const now = this.abs;
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
) {
|
|
622
|
-
return null;
|
|
623
|
-
}
|
|
618
|
+
const corner = (rect) =>
|
|
619
|
+
Math.min(radiusOf(style, rect), rect.width / 2, rect.height / 2);
|
|
620
|
+
const radius = Math.max(radiusOf(style, old), radiusOf(style, now));
|
|
621
|
+
if (radius > 0 && corner(old) !== corner(now)) return null;
|
|
624
622
|
const bw = resolveBorderWidths(style, this.direction);
|
|
625
623
|
const reach =
|
|
626
|
-
Math.max(
|
|
627
|
-
|
|
628
|
-
bw.top,
|
|
629
|
-
bw.right,
|
|
630
|
-
bw.bottom,
|
|
631
|
-
bw.left,
|
|
632
|
-
grow,
|
|
633
|
-
) + DAMAGE_SLOP;
|
|
624
|
+
Math.max(radius, bw.top, bw.right, bw.bottom, bw.left, grow) +
|
|
625
|
+
DAMAGE_SLOP;
|
|
634
626
|
const bands = [];
|
|
635
627
|
const oldBottom = old.y + old.height;
|
|
636
628
|
const newBottom = now.y + now.height;
|
package/src/nodes/moveblit.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// mistake it can make is not firing.
|
|
17
17
|
|
|
18
18
|
import { cssColorStraight } from 'ntk/color';
|
|
19
|
-
import { EMPTY_STYLE, resolveBorderWidths } from '../styles.js';
|
|
19
|
+
import { EMPTY_STYLE, radiusOf, resolveBorderWidths } from '../styles.js';
|
|
20
20
|
import { isPaintedColor } from './boxpaint.js';
|
|
21
21
|
import { DAMAGE_SLOP, FULL_DAMAGE, addDamageRect } from './damage.js';
|
|
22
22
|
import { NodePaint } from './paint.js';
|
|
@@ -100,7 +100,7 @@ function cornerSquares(box, radius) {
|
|
|
100
100
|
* Null when nothing is left.
|
|
101
101
|
*/
|
|
102
102
|
function clipBoxOf(node) {
|
|
103
|
-
const radius = node.style
|
|
103
|
+
const radius = radiusOf(node.style, node.abs);
|
|
104
104
|
return innerPixels(radius > 0 ? insetRect(node.abs, radius) : node.abs);
|
|
105
105
|
}
|
|
106
106
|
|
|
@@ -166,9 +166,9 @@ export class NodeMoveBlit {
|
|
|
166
166
|
const style = node.style ?? EMPTY_STYLE;
|
|
167
167
|
if (style.display === 'none') return null;
|
|
168
168
|
if (style.opacity !== undefined && !(style.opacity >= 1)) return null;
|
|
169
|
-
const radius = style.
|
|
169
|
+
const radius = radiusOf(style, node.abs);
|
|
170
170
|
let own = null;
|
|
171
|
-
if (isOpaqueColor(style.backgroundColor)) {
|
|
171
|
+
if (isOpaqueColor(node.resolveColor(style.backgroundColor))) {
|
|
172
172
|
own = node.abs;
|
|
173
173
|
holes.push(...cornerSquares(node.abs, radius));
|
|
174
174
|
} else {
|
|
@@ -581,7 +581,7 @@ export class WindowMoveBlit {
|
|
|
581
581
|
if (ring > 0) {
|
|
582
582
|
const inner = insetRect(
|
|
583
583
|
n.abs,
|
|
584
|
-
Math.max(ring, style.
|
|
584
|
+
Math.max(ring, radiusOf(style, n.abs)) + DAMAGE_SLOP,
|
|
585
585
|
);
|
|
586
586
|
const outer = insetRect(n.abs, -DAMAGE_SLOP);
|
|
587
587
|
for (const piece of outside(outer, inner)) over(piece);
|