react-x11 2.1.2 → 2.1.4
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 +2 -2
- package/src/ClickToComponent.js +111 -15
- package/src/components/Menu.js +199 -73
- package/src/components/Select.js +0 -1
- package/src/components/anchor.js +19 -10
- package/src/foreignnodes.js +7 -0
- package/src/glnodes.js +7 -0
- package/src/nodes.js +152 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.4",
|
|
4
4
|
"description": "react renderer with X11 as a target",
|
|
5
5
|
"main": "./src/index.js",
|
|
6
6
|
"files": [
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
"node": ">=20.19"
|
|
84
84
|
},
|
|
85
85
|
"dependencies": {
|
|
86
|
-
"ntk": "^8.
|
|
86
|
+
"ntk": "^8.5.0",
|
|
87
87
|
"react-reconciler": "^0.33.0",
|
|
88
88
|
"yoga-layout": "^3.2.1"
|
|
89
89
|
},
|
package/src/ClickToComponent.js
CHANGED
|
@@ -11,14 +11,44 @@
|
|
|
11
11
|
// original source — we only have to parse the stack text and skip the
|
|
12
12
|
// frames inside React/the reconciler itself.
|
|
13
13
|
import { spawn } from 'node:child_process';
|
|
14
|
+
import { dirname, sep } from 'node:path';
|
|
14
15
|
import { fileURLToPath } from 'node:url';
|
|
15
16
|
import { setClickToComponentHandler } from './events.js';
|
|
16
17
|
import { selectInDevTools } from './DevToolsIntegration.js';
|
|
17
18
|
|
|
18
19
|
const STACK_FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?\s*$/;
|
|
19
20
|
|
|
21
|
+
// The frames to walk past before the JSX call site itself: React captures
|
|
22
|
+
// the Error inside `jsxDEV`/`createElement`, and an element created by this
|
|
23
|
+
// renderer (or by node internals) is never what a click means. `SELF_DIR` is
|
|
24
|
+
// whichever copy of react-x11 is running — the repo's own `src/` for the
|
|
25
|
+
// examples and tests, an installed `node_modules/react-x11/src` for an app.
|
|
26
|
+
//
|
|
27
|
+
// Everything *else* in `node_modules` is deliberately not skipped here: a
|
|
28
|
+
// frame inside an installed package is a real call site, it is just not one
|
|
29
|
+
// in the application's source, and telling the two apart is what lets
|
|
30
|
+
// `resolveOwnedLocation` climb to the owner that is. Skipping every
|
|
31
|
+
// `node_modules` frame instead would walk straight past the library
|
|
32
|
+
// component and land on whatever application frame happens to be deeper in
|
|
33
|
+
// the render stack — usually the `render()` call at startup, which looks
|
|
34
|
+
// like an answer and isn't.
|
|
35
|
+
const REACT_RUNTIME =
|
|
36
|
+
/[\\/]node_modules[\\/](react|react-dom|react-reconciler|scheduler)[\\/]/;
|
|
37
|
+
const SELF_DIR = dirname(fileURLToPath(import.meta.url)) + sep;
|
|
38
|
+
|
|
39
|
+
function isInternalFrame(file) {
|
|
40
|
+
return (
|
|
41
|
+
file.startsWith('node:') ||
|
|
42
|
+
file === '<anonymous>' ||
|
|
43
|
+
file.startsWith(SELF_DIR) ||
|
|
44
|
+
REACT_RUNTIME.test(file)
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
20
48
|
/** The first stack frame outside React/the reconciler/node internals — the
|
|
21
|
-
*
|
|
49
|
+
* JSX call site for whatever element this Error was captured at, wherever it
|
|
50
|
+
* was written. `installed` marks a call site that lives inside an installed
|
|
51
|
+
* package rather than in the application's own source.
|
|
22
52
|
* Exported because `react-x11/test`'s `sourceOf` answers the same question
|
|
23
53
|
* for a test that a click answers for an editor. */
|
|
24
54
|
export function resolveLocation(debugStack) {
|
|
@@ -28,26 +58,54 @@ export function resolveLocation(debugStack) {
|
|
|
28
58
|
const match = line.match(STACK_FRAME);
|
|
29
59
|
if (!match) continue;
|
|
30
60
|
const [, functionName, rawFile, lineStr, columnStr] = match;
|
|
31
|
-
if (
|
|
32
|
-
rawFile.includes('/node_modules/') ||
|
|
33
|
-
rawFile.startsWith('node:') ||
|
|
34
|
-
rawFile === '<anonymous>'
|
|
35
|
-
) {
|
|
36
|
-
continue;
|
|
37
|
-
}
|
|
38
61
|
const file = rawFile.startsWith('file://')
|
|
39
62
|
? fileURLToPath(rawFile)
|
|
40
63
|
: rawFile;
|
|
64
|
+
if (isInternalFrame(file)) continue;
|
|
41
65
|
return {
|
|
42
66
|
functionName,
|
|
43
67
|
file,
|
|
44
68
|
line: Number(lineStr),
|
|
45
69
|
column: Number(columnStr),
|
|
70
|
+
installed: file.includes(`${sep}node_modules${sep}`),
|
|
46
71
|
};
|
|
47
72
|
}
|
|
48
73
|
return null;
|
|
49
74
|
}
|
|
50
75
|
|
|
76
|
+
/** The nearest source location a click can mean: the clicked element's own
|
|
77
|
+
* JSX call site when the application wrote it, and otherwise the first owner
|
|
78
|
+
* up the chain that it did write. An element rendered by an installed
|
|
79
|
+
* component — a design system's `<Toolbar>`, a chart library's internals —
|
|
80
|
+
* has its call site inside that package; what the user meant by clicking it
|
|
81
|
+
* is the `<Toolbar ... />` line in their own file that put it on screen.
|
|
82
|
+
*
|
|
83
|
+
* Returns `{ fiber, location, depth }`, `depth` being how many owners up the
|
|
84
|
+
* location came from (0 = the clicked element's own). Falls back to the
|
|
85
|
+
* nearest installed call site when nothing in the chain is application
|
|
86
|
+
* source — opening a package's own file beats refusing to open anything —
|
|
87
|
+
* and null only when React has no debug info at all. */
|
|
88
|
+
export function resolveOwnedLocation(fiber) {
|
|
89
|
+
let fallback = null;
|
|
90
|
+
let depth = 0;
|
|
91
|
+
for (let owner = fiber; owner; owner = owner._debugOwner, depth++) {
|
|
92
|
+
const location = resolveLocation(owner._debugStack);
|
|
93
|
+
if (!location) continue;
|
|
94
|
+
if (!location.installed) return { fiber: owner, location, depth };
|
|
95
|
+
fallback ??= { fiber: owner, location, depth };
|
|
96
|
+
}
|
|
97
|
+
return fallback;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** What the clicked thing *is* — `<box>` for a host node, the component's
|
|
101
|
+
* name for a composite one. (`componentName` below answers a different
|
|
102
|
+
* question: who *wrote* it.) */
|
|
103
|
+
function elementLabel(fiber) {
|
|
104
|
+
const type = fiber.type;
|
|
105
|
+
if (typeof type === 'string') return `<${type}>`;
|
|
106
|
+
return type?.displayName || type?.name || '(anonymous)';
|
|
107
|
+
}
|
|
108
|
+
|
|
51
109
|
function componentName(fiber) {
|
|
52
110
|
const owner = fiber._debugOwner;
|
|
53
111
|
const type = owner?.type;
|
|
@@ -119,13 +177,39 @@ function openInEditor({ file, line, column }) {
|
|
|
119
177
|
bin = OPEN_URI_COMMAND;
|
|
120
178
|
args = [editorUri(scheme, file, line, column)];
|
|
121
179
|
}
|
|
122
|
-
|
|
180
|
+
// stderr is kept (rather than the whole stdio ignored) for one reason:
|
|
181
|
+
// `open`/`xdg-open` exit non-zero and print *there* when nothing claims
|
|
182
|
+
// the scheme — an editor that isn't installed, or one whose URL handler
|
|
183
|
+
// was never registered. Swallowing that is what makes click-to-component
|
|
184
|
+
// look like it only logs: the location resolves, the console line prints,
|
|
185
|
+
// and the editor silently never opens.
|
|
186
|
+
const child = spawn(bin, args, {
|
|
187
|
+
detached: true,
|
|
188
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
189
|
+
});
|
|
190
|
+
let stderr = '';
|
|
191
|
+
// ...and unref'd, so keeping it does not keep the process alive: a pipe is
|
|
192
|
+
// a ref'd handle, and an editor that outlives the app it was launched from
|
|
193
|
+
// is the normal case, not a reason to hold the event loop open.
|
|
194
|
+
child.stderr.unref();
|
|
195
|
+
child.stderr.on('data', (chunk) => {
|
|
196
|
+
stderr += chunk;
|
|
197
|
+
});
|
|
123
198
|
child.on('error', (err) => {
|
|
124
199
|
console.warn(
|
|
125
200
|
`react-x11: click-to-component could not launch "${bin}" (${err.code ?? err.message}). ` +
|
|
126
201
|
'Set REACT_X11_EDITOR to your editor CLI (cursor, code, code-insiders, windsurf, vim, nvim).',
|
|
127
202
|
);
|
|
128
203
|
});
|
|
204
|
+
child.on('exit', (code) => {
|
|
205
|
+
if (!code) return;
|
|
206
|
+
console.warn(
|
|
207
|
+
`react-x11: click-to-component — \`${bin} ${args.join(' ')}\` exited ${code}. ` +
|
|
208
|
+
(stderr.trim() ? `${stderr.trim()} ` : '') +
|
|
209
|
+
`Set REACT_X11_EDITOR to the editor you actually use (currently "${name}"; ` +
|
|
210
|
+
'cursor, code, code-insiders, windsurf, vim, nvim, or any registered URI scheme).',
|
|
211
|
+
);
|
|
212
|
+
});
|
|
129
213
|
child.unref();
|
|
130
214
|
}
|
|
131
215
|
|
|
@@ -146,17 +230,29 @@ function handleClick(node, native) {
|
|
|
146
230
|
);
|
|
147
231
|
return;
|
|
148
232
|
}
|
|
149
|
-
const
|
|
150
|
-
if (!
|
|
233
|
+
const resolved = resolveOwnedLocation(fiber);
|
|
234
|
+
if (!resolved) {
|
|
151
235
|
console.warn(
|
|
152
|
-
'react-x11: click-to-component — no source location found
|
|
153
|
-
'React running in
|
|
236
|
+
'react-x11: click-to-component — no source location found for this ' +
|
|
237
|
+
'element or any of its owners. This needs React running in ' +
|
|
238
|
+
'development mode (fiber._debugStack).',
|
|
154
239
|
);
|
|
155
240
|
return;
|
|
156
241
|
}
|
|
242
|
+
const { fiber: sourceFiber, location, depth } = resolved;
|
|
243
|
+
// What was clicked is not always what has a source: say so rather than
|
|
244
|
+
// silently opening a file the click doesn't obviously correspond to.
|
|
245
|
+
const climbed =
|
|
246
|
+
depth > 0
|
|
247
|
+
? ` (${depth} owner${depth > 1 ? 's' : ''} up from the clicked ` +
|
|
248
|
+
`${elementLabel(fiber)})`
|
|
249
|
+
: '';
|
|
250
|
+
const installed = location.installed ? ' — inside an installed package' : '';
|
|
157
251
|
console.log(
|
|
158
|
-
`[click-to-component] ${componentName(
|
|
159
|
-
`${location.file}:${location.line}:${location.column}
|
|
252
|
+
`[click-to-component] ${componentName(sourceFiber)} → ` +
|
|
253
|
+
`${location.file}:${location.line}:${location.column}` +
|
|
254
|
+
climbed +
|
|
255
|
+
installed,
|
|
160
256
|
);
|
|
161
257
|
if (native?.buttons & 1) {
|
|
162
258
|
// Alt+Shift+Click
|
package/src/components/Menu.js
CHANGED
|
@@ -59,7 +59,12 @@ const h = React.createElement;
|
|
|
59
59
|
const MENU_ITEM_PAD = 8;
|
|
60
60
|
const menuRowHeight = (fontSize) => capBand(fontSize) + MENU_ITEM_PAD * 2;
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
// A separator is the hairline plus the air that makes it a division rather
|
|
63
|
+
// than a row with a line through it — the two groups it stands between have
|
|
64
|
+
// to read as apart, and at three pixels a side the line just crowds the
|
|
65
|
+
// labels above and below it.
|
|
66
|
+
const MENU_SEPARATOR_PAD = 5;
|
|
67
|
+
const MENU_SEPARATOR_HEIGHT = MENU_SEPARATOR_PAD * 2 + 1;
|
|
63
68
|
|
|
64
69
|
const MENU_MIN_WIDTH = 140;
|
|
65
70
|
|
|
@@ -76,28 +81,47 @@ const MENU_PAD = 4;
|
|
|
76
81
|
// borders on its *controls* does not mean a 2px outline around every menu.
|
|
77
82
|
const MENU_BORDER = 1;
|
|
78
83
|
|
|
79
|
-
// How far a bar item's pill sits inside the bar, taken out of its padding so
|
|
80
|
-
// the bar's height does not change. The strip carries the same inset at its
|
|
81
|
-
// two ends: a pill that starts in the window's own corner reads as part of
|
|
82
|
-
// the frame rather than as something on a strip, and the first menu is the
|
|
83
|
-
// one every pointer arrives at.
|
|
84
|
-
const BAR_INSET = 3;
|
|
85
|
-
// the gap between two pills, split between them
|
|
86
|
-
const BAR_GAP = 1;
|
|
87
|
-
|
|
88
84
|
// A bar item wears the same pill as a row in the menu it opens, so it takes
|
|
89
85
|
// the row's padding rather than numbers of its own: a title packed tighter
|
|
90
86
|
// than its own first row is the tell that the two were measured separately.
|
|
91
87
|
// Vertically that is `MENU_ITEM_PAD` exactly — same padding, same `capTrim`
|
|
92
|
-
// text — which makes the pill `menuRowHeight` tall
|
|
93
|
-
//
|
|
88
|
+
// text — which makes the pill `menuRowHeight` tall and the strip exactly
|
|
89
|
+
// that: a menu bar is one row, and its highlight fills it top to bottom the
|
|
90
|
+
// way a real one does. Inset the pill instead and the bar is a row plus two
|
|
91
|
+
// margins tall, with a highlight floating in a band of leftover strip.
|
|
92
|
+
//
|
|
93
|
+
// Horizontally it takes a little more, and the number is measured rather
|
|
94
|
+
// than argued: the bar this one is imitating leaves 22px between the ink of
|
|
95
|
+
// two titles, so that is what a title's own padding and its neighbour's have
|
|
96
|
+
// to add up to. A row's label in a menu with a check column is
|
|
97
|
+
// `MENU_ITEM_PAD + MENU_GUTTER` from the pill's edge anyway, so the row's own
|
|
98
|
+
// padding would read as the cramped one here — a title has no column to sit
|
|
99
|
+
// past, but it does sit shoulder to shoulder with the next title.
|
|
94
100
|
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
// between
|
|
100
|
-
|
|
101
|
+
const BAR_ITEM_PAD_X = MENU_ITEM_PAD + 3;
|
|
102
|
+
|
|
103
|
+
// The pill is wider than the item that owns it. Measured off the same bar:
|
|
104
|
+
// its highlight runs 16px past the ink of the title it belongs to, which is
|
|
105
|
+
// five past the halfway line between that title and the next — so a lit
|
|
106
|
+
// title reaches *into* both neighbours' halves of the strip rather than
|
|
107
|
+
// stopping politely at the boundary. It reads as one object sitting on the
|
|
108
|
+
// bar; a pill that stops at the midpoint reads as one cell of a table that
|
|
109
|
+
// happens to be filled in.
|
|
110
|
+
//
|
|
111
|
+
// Drawn, not laid out. The item box still tiles the strip, so the 22px
|
|
112
|
+
// between two titles is still two paddings and the pointer still belongs to
|
|
113
|
+
// exactly one item everywhere along the bar. Widening the boxes to the size
|
|
114
|
+
// of the pill instead would overlap them by ten pixels, and then the last
|
|
115
|
+
// five of a title's own highlight would open its neighbour's menu.
|
|
116
|
+
const BAR_PILL_BLEED = 5;
|
|
117
|
+
|
|
118
|
+
// What is left of the strip at its two ends, before the first pill and after
|
|
119
|
+
// the last: a highlight cut off square by the window's edge is the one place
|
|
120
|
+
// a pill has no room to be a pill. Measured like the rest — a native bar
|
|
121
|
+
// leaves five — and the strip's padding is that plus the bleed, since the
|
|
122
|
+
// pill starts before its item does.
|
|
123
|
+
const BAR_END_INSET = 5;
|
|
124
|
+
const BAR_END_PAD = BAR_END_INSET + BAR_PILL_BLEED;
|
|
101
125
|
|
|
102
126
|
// The bar entry that stands for the titles that did not fit. A symbol rather
|
|
103
127
|
// than a `label`, because it is the one entry on the bar the application did
|
|
@@ -119,9 +143,29 @@ const OVERFLOW_KEY = '\0menubar-overflow';
|
|
|
119
143
|
// way (`movingToward`), so this is a matter of taste rather than of reach.
|
|
120
144
|
const SUBMENU_GAP = 0;
|
|
121
145
|
|
|
122
|
-
|
|
123
|
-
// what a self-drawing icon gets to fill, inside that column's 16px
|
|
146
|
+
// what a self-drawing icon gets to fill
|
|
124
147
|
const MENU_ICON_SIZE = 12;
|
|
148
|
+
// The air between that mark and the label it belongs to. A mark set against
|
|
149
|
+
// its label with a pixel or two to spare reads as part of the word rather
|
|
150
|
+
// than as a column of its own — but the column is an indent every label in
|
|
151
|
+
// the menu pays for, so it is the smallest gap that still reads as one:
|
|
152
|
+
// enough to separate two things, less than the space between two words.
|
|
153
|
+
const MENU_ICON_GAP = 6;
|
|
154
|
+
// The check column: the mark's own box plus that gap. The mark starts at the
|
|
155
|
+
// row's padding — flush with where a label starts in a menu that has no
|
|
156
|
+
// column at all — so the whole of the column's width is the space after it.
|
|
157
|
+
const MENU_GUTTER = MENU_ICON_SIZE + MENU_ICON_GAP;
|
|
158
|
+
|
|
159
|
+
// Menu text sits a step above the body weight. A menu is read in glances
|
|
160
|
+
// rather than in sentences — a title on a strip, a row under the pointer —
|
|
161
|
+
// and the native ones are all set a shade heavier for it. Where the face has
|
|
162
|
+
// a medium this picks it; where it has only a regular and a bold, 500 is
|
|
163
|
+
// nearer the regular and nothing changes, which is the right failure.
|
|
164
|
+
//
|
|
165
|
+
// Every label measured for a popup's width is measured at this weight too
|
|
166
|
+
// (`measureLabel` takes it), since a menu sized in regular for rows drawn in
|
|
167
|
+
// medium is a menu whose own labels run into its shortcuts.
|
|
168
|
+
const MENU_TEXT_WEIGHT = 500;
|
|
125
169
|
|
|
126
170
|
const MENU_SHORTCUT_GAP = 24;
|
|
127
171
|
// menus size to their content rather than scrolling, so a page is a fixed
|
|
@@ -138,6 +182,37 @@ function menuListHeight(items, fontSize) {
|
|
|
138
182
|
return body + (MENU_PAD + MENU_BORDER) * 2;
|
|
139
183
|
}
|
|
140
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The width of this menu's check column, which is `0` for a menu with
|
|
187
|
+
* nothing to draw in it.
|
|
188
|
+
*
|
|
189
|
+
* The question is asked of the level rather than of the row — one mark
|
|
190
|
+
* anywhere in a menu indents every label in it, which is what puts the
|
|
191
|
+
* labels in a column of their own — and what counts is what is *drawn*, not
|
|
192
|
+
* what could be. A checkbox that is off draws nothing, so a menu of unticked
|
|
193
|
+
* toggles is a menu of plain commands as far as the eye is concerned, and it
|
|
194
|
+
* gets the plain menu's left edge.
|
|
195
|
+
*
|
|
196
|
+
* The cost is the one every toolkit reserving this column is avoiding:
|
|
197
|
+
* ticking the first item in such a menu moves its labels sideways. That is
|
|
198
|
+
* the trade this codebase makes deliberately — an indent on every menu with
|
|
199
|
+
* a toggle anywhere in it, paid by every label in it, buys stillness in the
|
|
200
|
+
* one frame where something is ticked. A menu that already has a mark keeps
|
|
201
|
+
* the column whatever happens to the rest, so the movement is once at the
|
|
202
|
+
* boundary rather than on every tick.
|
|
203
|
+
*
|
|
204
|
+
* `toggleState` is dbusmenu's three-state one: `-1` draws a dash and counts,
|
|
205
|
+
* `0` draws nothing and does not. A separator answers for nothing either way,
|
|
206
|
+
* since it spans the row.
|
|
207
|
+
*/
|
|
208
|
+
function menuGutter(items) {
|
|
209
|
+
const wanted = visibleItems(items).some(
|
|
210
|
+
(item) =>
|
|
211
|
+
!isSeparator(item) && (toggleMark(item) != null || item.icon != null),
|
|
212
|
+
);
|
|
213
|
+
return wanted ? MENU_GUTTER : 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
141
216
|
/** Widest label + shortcut, measured, so the popup can be sized up front. */
|
|
142
217
|
function menuListWidth(node, items, fontSize) {
|
|
143
218
|
let widest = 0;
|
|
@@ -145,20 +220,23 @@ function menuListWidth(node, items, fontSize) {
|
|
|
145
220
|
if (isSeparator(item)) continue;
|
|
146
221
|
const label = measureLabel(node, item.label ?? '', {
|
|
147
222
|
size: fontSize,
|
|
223
|
+
weight: MENU_TEXT_WEIGHT,
|
|
148
224
|
}).width;
|
|
149
225
|
const accelerator = formatShortcut(item.shortcut);
|
|
150
226
|
const shortcut = accelerator
|
|
151
|
-
? measureLabel(node, accelerator, {
|
|
152
|
-
|
|
227
|
+
? measureLabel(node, accelerator, {
|
|
228
|
+
size: fontSize,
|
|
229
|
+
weight: MENU_TEXT_WEIGHT,
|
|
230
|
+
}).width + MENU_SHORTCUT_GAP
|
|
153
231
|
: 0;
|
|
154
232
|
widest = Math.max(widest, label + shortcut);
|
|
155
233
|
}
|
|
156
234
|
return Math.max(
|
|
157
235
|
MENU_MIN_WIDTH,
|
|
158
236
|
Math.ceil(widest) +
|
|
159
|
-
|
|
237
|
+
menuGutter(items) +
|
|
160
238
|
(MENU_PAD + MENU_BORDER) * 2 +
|
|
161
|
-
MENU_ITEM_PAD +
|
|
239
|
+
MENU_ITEM_PAD * 2 +
|
|
162
240
|
2,
|
|
163
241
|
);
|
|
164
242
|
}
|
|
@@ -169,13 +247,15 @@ function menuListWidth(node, items, fontSize) {
|
|
|
169
247
|
* `menuListWidth` so the two cannot drift.
|
|
170
248
|
*/
|
|
171
249
|
function barItemWidth(node, label, fontSize) {
|
|
172
|
-
const text = measureLabel(node, label ?? '', {
|
|
173
|
-
|
|
250
|
+
const text = measureLabel(node, label ?? '', {
|
|
251
|
+
size: fontSize,
|
|
252
|
+
weight: MENU_TEXT_WEIGHT,
|
|
253
|
+
}).width;
|
|
254
|
+
return Math.ceil(text) + BAR_ITEM_PAD_X * 2;
|
|
174
255
|
}
|
|
175
256
|
|
|
176
257
|
/** The same, for the chevron: an icon box where a title has its label. */
|
|
177
|
-
const barOverflowWidth = (fontSize) =>
|
|
178
|
-
iconSize(fontSize) + (BAR_ITEM_PAD_X + BAR_GAP) * 2;
|
|
258
|
+
const barOverflowWidth = (fontSize) => iconSize(fontSize) + BAR_ITEM_PAD_X * 2;
|
|
179
259
|
|
|
180
260
|
/**
|
|
181
261
|
* How many titles the bar can paint, and therefore where it is cut. The rest
|
|
@@ -195,7 +275,7 @@ const barOverflowWidth = (fontSize) =>
|
|
|
195
275
|
*/
|
|
196
276
|
function barCut(node, menus, fontSize, width) {
|
|
197
277
|
const widths = menus.map((menu) => barItemWidth(node, menu.label, fontSize));
|
|
198
|
-
const inner = width -
|
|
278
|
+
const inner = width - BAR_END_PAD * 2;
|
|
199
279
|
const total = widths.reduce((sum, w) => sum + w, 0);
|
|
200
280
|
if (total <= inner) return menus.length;
|
|
201
281
|
const room = inner - barOverflowWidth(fontSize);
|
|
@@ -314,6 +394,10 @@ function MenuRow({
|
|
|
314
394
|
onMove,
|
|
315
395
|
onSelect,
|
|
316
396
|
fontSize,
|
|
397
|
+
// The width of the level's check column — the same number the popup was
|
|
398
|
+
// sized with, passed down rather than asked of the item, since a row with
|
|
399
|
+
// no mark of its own still keeps the column its neighbours need.
|
|
400
|
+
gutter,
|
|
317
401
|
nodeRef,
|
|
318
402
|
}) {
|
|
319
403
|
const theme = useTheme();
|
|
@@ -369,7 +453,6 @@ function MenuRow({
|
|
|
369
453
|
alignItems: 'center',
|
|
370
454
|
paddingLeft: MENU_ITEM_PAD,
|
|
371
455
|
paddingRight: MENU_ITEM_PAD,
|
|
372
|
-
cursor: dim ? undefined : 'pointer',
|
|
373
456
|
// A pill inside the sheet: the row is inset from the popup edge by
|
|
374
457
|
// the list's padding, and rounded so that its corner and the sheet's
|
|
375
458
|
// share a centre — the two curves are then one shape rather than two
|
|
@@ -397,21 +480,37 @@ function MenuRow({
|
|
|
397
480
|
: { ':active': { backgroundColor: theme.accentActive } }),
|
|
398
481
|
},
|
|
399
482
|
},
|
|
483
|
+
gutter > 0 &&
|
|
484
|
+
h(
|
|
485
|
+
'box',
|
|
486
|
+
{
|
|
487
|
+
// The mark sits at the head of the column and the gap is what
|
|
488
|
+
// follows it, rather than the column centring its box and leaving
|
|
489
|
+
// half the gap on either side: the label is the thing that has to
|
|
490
|
+
// stand clear, and the row's own padding already spaces the mark.
|
|
491
|
+
style: { width: gutter, alignItems: 'flex-start' },
|
|
492
|
+
},
|
|
493
|
+
gutterMark(item, { color: rowInk, fontSize }),
|
|
494
|
+
),
|
|
400
495
|
h(
|
|
401
|
-
'
|
|
402
|
-
{ style: {
|
|
403
|
-
|
|
496
|
+
'text',
|
|
497
|
+
{ style: [capTrim, { fontSize, fontWeight: MENU_TEXT_WEIGHT }] },
|
|
498
|
+
item.label,
|
|
404
499
|
),
|
|
405
|
-
h('text', { style: [capTrim, { fontSize }] }, item.label),
|
|
406
500
|
h('box', { style: { flexGrow: 1 } }),
|
|
407
|
-
// The accelerator
|
|
408
|
-
//
|
|
409
|
-
//
|
|
501
|
+
// The accelerator is quieter than the label at rest and rises with the
|
|
502
|
+
// row when it is chosen — so on an active row it says nothing and takes
|
|
503
|
+
// what the row set. A shortcut is a second way to do what the label
|
|
504
|
+
// already says; the chevron below it is not, and keeps the row's ink.
|
|
410
505
|
accelerator &&
|
|
411
506
|
h(
|
|
412
507
|
'text',
|
|
413
508
|
{
|
|
414
|
-
style: [
|
|
509
|
+
style: [
|
|
510
|
+
capTrim,
|
|
511
|
+
{ fontSize, fontWeight: MENU_TEXT_WEIGHT },
|
|
512
|
+
!active && { color: theme.textMuted },
|
|
513
|
+
],
|
|
415
514
|
},
|
|
416
515
|
accelerator,
|
|
417
516
|
),
|
|
@@ -424,7 +523,9 @@ function MenuRow({
|
|
|
424
523
|
// stands as tall as its box, so `MENU_ICON_SIZE` would put an arrow
|
|
425
524
|
// beside the label taller than the label.
|
|
426
525
|
size: capBand(fontSize),
|
|
427
|
-
|
|
526
|
+
// no `color`: the arrow is the row saying it has more behind it, as
|
|
527
|
+
// much a part of the entry as its label, and a muted one reads as a
|
|
528
|
+
// row that is half disabled rather than one with a submenu
|
|
428
529
|
}),
|
|
429
530
|
);
|
|
430
531
|
}
|
|
@@ -463,6 +564,9 @@ function MenuLevel({
|
|
|
463
564
|
}) {
|
|
464
565
|
const theme = useTheme();
|
|
465
566
|
const items = levelItems(rootItems, path, depth);
|
|
567
|
+
// one answer for the level, so every row indents by the same amount the
|
|
568
|
+
// popup was measured with
|
|
569
|
+
const gutter = menuGutter(items);
|
|
466
570
|
const active = path[depth] ?? -1;
|
|
467
571
|
const childItems = visibleItems(items[active]?.items);
|
|
468
572
|
const childOpen = path.length > depth + 1 && childItems.length > 0;
|
|
@@ -621,6 +725,7 @@ function MenuLevel({
|
|
|
621
725
|
item,
|
|
622
726
|
state: rowState(index, active, handedOn),
|
|
623
727
|
fontSize,
|
|
728
|
+
gutter,
|
|
624
729
|
nodeRef: index === active ? activeRowRef : undefined,
|
|
625
730
|
onHover: (ev) => hover(index, ev),
|
|
626
731
|
onMove: (ev) => move(index, ev),
|
|
@@ -902,6 +1007,11 @@ export function MenuBar({
|
|
|
902
1007
|
const theme = useTheme();
|
|
903
1008
|
const rtl = useDirection() === 'rtl';
|
|
904
1009
|
const [openIndex, setOpenIndex] = useState(-1);
|
|
1010
|
+
// The pointer's title, tracked here rather than left to a `:hover` block,
|
|
1011
|
+
// because the thing that lights up is no longer the node the pointer is
|
|
1012
|
+
// over: the pill is a child that reaches past its own item, and a state
|
|
1013
|
+
// block only ever answers for the node it is written on.
|
|
1014
|
+
const [hoverIndex, setHoverIndex] = useState(-1);
|
|
905
1015
|
const [rect, setRect] = useState(null);
|
|
906
1016
|
const [path, setPath] = useState([]);
|
|
907
1017
|
const refs = useRef([]);
|
|
@@ -1120,8 +1230,8 @@ export function MenuBar({
|
|
|
1120
1230
|
flexDirection: 'row',
|
|
1121
1231
|
alignItems: 'center',
|
|
1122
1232
|
backgroundColor: theme.surfaceHover,
|
|
1123
|
-
paddingLeft:
|
|
1124
|
-
paddingRight:
|
|
1233
|
+
paddingLeft: BAR_END_PAD,
|
|
1234
|
+
paddingRight: BAR_END_PAD,
|
|
1125
1235
|
// `scroll` is what makes a box report its viewport, and the clip
|
|
1126
1236
|
// that comes with it is wanted in its own right: it is what holds
|
|
1127
1237
|
// the one frame before the first measurement — and any bar whose
|
|
@@ -1172,10 +1282,17 @@ export function MenuBar({
|
|
|
1172
1282
|
onMouseDown: () =>
|
|
1173
1283
|
openIndex === index ? close() : openMenu(index, 'pointer'),
|
|
1174
1284
|
onMouseEnter: () => {
|
|
1285
|
+
setHoverIndex(index);
|
|
1175
1286
|
if (openIndex >= 0 && openIndex !== index) {
|
|
1176
1287
|
openMenu(index, 'pointer');
|
|
1177
1288
|
}
|
|
1178
1289
|
},
|
|
1290
|
+
onMouseLeave: () => {
|
|
1291
|
+
// the index rather than -1 unconditionally: the pointer reaches
|
|
1292
|
+
// the next title before this one hears it left, and clearing
|
|
1293
|
+
// then would put the bar back to nothing lit for a frame
|
|
1294
|
+
setHoverIndex((current) => (current === index ? -1 : current));
|
|
1295
|
+
},
|
|
1179
1296
|
// read live: by the time a hand-off blurs this item, the item
|
|
1180
1297
|
// taking over has already claimed the bar
|
|
1181
1298
|
onBlur: () => {
|
|
@@ -1226,33 +1343,14 @@ export function MenuBar({
|
|
|
1226
1343
|
},
|
|
1227
1344
|
style: [
|
|
1228
1345
|
{
|
|
1229
|
-
cursor: 'pointer',
|
|
1230
1346
|
paddingLeft: BAR_ITEM_PAD_X,
|
|
1231
1347
|
paddingRight: BAR_ITEM_PAD_X,
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1237
|
-
// The margin is what a radius needs to be seen — a rounded
|
|
1238
|
-
// rect flush against the strip's own edges reads as a cut
|
|
1239
|
-
// corner rather than a pill. It sits *outside* the padding, so
|
|
1240
|
-
// the bar is a menu row plus its two insets tall: the item and
|
|
1241
|
-
// the row below it are then the same pill in the same size,
|
|
1242
|
-
// which is the whole point of giving the bar one.
|
|
1243
|
-
marginTop: BAR_INSET,
|
|
1244
|
-
marginBottom: BAR_INSET,
|
|
1245
|
-
marginLeft: BAR_GAP,
|
|
1246
|
-
marginRight: BAR_GAP,
|
|
1348
|
+
// No vertical margin: the pill fills the strip, which is a
|
|
1349
|
+
// menu row tall and nothing more. The item and the first row
|
|
1350
|
+
// of the menu it opens are then the same pill in the same
|
|
1351
|
+
// size, which is the whole point of giving the bar one.
|
|
1247
1352
|
paddingTop: MENU_ITEM_PAD,
|
|
1248
1353
|
paddingBottom: MENU_ITEM_PAD,
|
|
1249
|
-
borderRadius: rowRadius(theme, MENU_BORDER, MENU_PAD),
|
|
1250
|
-
backgroundColor:
|
|
1251
|
-
barState === 'active'
|
|
1252
|
-
? theme.hoverBackground
|
|
1253
|
-
: barState === 'path'
|
|
1254
|
-
? theme.surfaceActive
|
|
1255
|
-
: undefined,
|
|
1256
1354
|
// No ring while this item's menu is up. Walking the bar with
|
|
1257
1355
|
// the arrow keys opens each menu as it arrives, so the item is
|
|
1258
1356
|
// already inverted with a menu hanging off it — a ring on top
|
|
@@ -1267,16 +1365,40 @@ export function MenuBar({
|
|
|
1267
1365
|
// said once for whichever of the two the title turns out to be
|
|
1268
1366
|
color: barState === 'active' ? theme.hoverText : theme.text,
|
|
1269
1367
|
},
|
|
1270
|
-
// Only while this menu is shut, as in `Select`: an open one is
|
|
1271
|
-
// already showing the answer, and a state block would outrank
|
|
1272
|
-
// the base colour that says so. The menu opens on the release,
|
|
1273
|
-
// so `:active` is the whole of the answer to a held press.
|
|
1274
|
-
openIndex !== index && {
|
|
1275
|
-
':hover': { backgroundColor: theme.surface },
|
|
1276
|
-
':active': { backgroundColor: theme.surfaceActive },
|
|
1277
|
-
},
|
|
1278
1368
|
],
|
|
1279
1369
|
},
|
|
1370
|
+
// The pill, drawn before the title so the title is drawn on it. It
|
|
1371
|
+
// is a box of its own rather than this item's background because it
|
|
1372
|
+
// is wider than this item: `BAR_PILL_BLEED` past both edges, which
|
|
1373
|
+
// is where a native bar's highlight ends (see the constant). Only
|
|
1374
|
+
// while it has a colour to be — nothing at rest, since the strip
|
|
1375
|
+
// under it is already that colour.
|
|
1376
|
+
//
|
|
1377
|
+
// Hover is the state that used to be a `:hover` block here and is
|
|
1378
|
+
// now `hoverIndex`, and only while this menu is shut: an open one is
|
|
1379
|
+
// already showing the answer, and the hover colour would outrank the
|
|
1380
|
+
// base colour that says so.
|
|
1381
|
+
(barState || hoverIndex === index) &&
|
|
1382
|
+
h('box', {
|
|
1383
|
+
style: {
|
|
1384
|
+
position: 'absolute',
|
|
1385
|
+
left: -BAR_PILL_BLEED,
|
|
1386
|
+
right: -BAR_PILL_BLEED,
|
|
1387
|
+
top: 0,
|
|
1388
|
+
bottom: 0,
|
|
1389
|
+
// the same radius the rows inside the menu wear: the bar item
|
|
1390
|
+
// and the first row of the menu it opens are one gesture, and
|
|
1391
|
+
// a square title over rounded rows reads as two widgets that
|
|
1392
|
+
// have not met
|
|
1393
|
+
borderRadius: rowRadius(theme, MENU_BORDER, MENU_PAD),
|
|
1394
|
+
backgroundColor:
|
|
1395
|
+
barState === 'active'
|
|
1396
|
+
? theme.hoverBackground
|
|
1397
|
+
: barState === 'path'
|
|
1398
|
+
? theme.surfaceActive
|
|
1399
|
+
: theme.surface,
|
|
1400
|
+
},
|
|
1401
|
+
}),
|
|
1280
1402
|
overflow
|
|
1281
1403
|
? h(Icon, {
|
|
1282
1404
|
// The set's own overflow mark, rather than the `»` Qt and
|
|
@@ -1288,7 +1410,11 @@ export function MenuBar({
|
|
|
1288
1410
|
name: 'moreVertical',
|
|
1289
1411
|
size: iconSize(fontSize),
|
|
1290
1412
|
})
|
|
1291
|
-
: h(
|
|
1413
|
+
: h(
|
|
1414
|
+
'text',
|
|
1415
|
+
{ style: [capTrim, { fontSize, fontWeight: MENU_TEXT_WEIGHT }] },
|
|
1416
|
+
menu.label,
|
|
1417
|
+
),
|
|
1292
1418
|
);
|
|
1293
1419
|
}),
|
|
1294
1420
|
openIndex >= 0 &&
|
package/src/components/Select.js
CHANGED
|
@@ -131,7 +131,6 @@ function Option({
|
|
|
131
131
|
justifyContent: 'center',
|
|
132
132
|
paddingLeft: ITEM_PAD_LEFT,
|
|
133
133
|
paddingRight: ITEM_PAD_RIGHT,
|
|
134
|
-
cursor: 'pointer',
|
|
135
134
|
// the menus' pill, for the same reason and by the same rule: an
|
|
136
135
|
// option list and a menu are one surface with rows in it, and two
|
|
137
136
|
// shapes for that would only say the widgets were written apart
|
package/src/components/anchor.js
CHANGED
|
@@ -165,18 +165,27 @@ export function measureLabel(node, text, style) {
|
|
|
165
165
|
// exactly the kind of error that turns into an ellipsis on the longest
|
|
166
166
|
// label in the menu (src/scale.js).
|
|
167
167
|
const s = node?.scale ?? 1;
|
|
168
|
+
// The face **this node inherits**, not the literal `sans-serif` at whatever
|
|
169
|
+
// the defaults are: the labels these popups are sized around name no type
|
|
170
|
+
// of their own, so the one they are drawn in is the one that cascades down
|
|
171
|
+
// to them. Measuring in a different face is measuring the wrong label — a
|
|
172
|
+
// menu sized in sans-serif for a row that paints in a wider mono is a menu
|
|
173
|
+
// whose own options wrap.
|
|
174
|
+
//
|
|
175
|
+
// Which is every property that changes a glyph's advance, not the family
|
|
176
|
+
// alone. `fontVariationSettings` is the one that bites: a tree that sets
|
|
177
|
+
// `{ opsz: 17 }` on its window — the text cut of a variable face, wider
|
|
178
|
+
// than the display cut most files default to — draws every row in it and
|
|
179
|
+
// used to measure none, and the popup came out narrow enough to wrap its
|
|
180
|
+
// own labels. An explicit `style` still wins, since a caller that names a
|
|
181
|
+
// face is measuring something it is about to draw in that face.
|
|
182
|
+
const inherited = node?.inheritedTextStyle;
|
|
168
183
|
const layout = fonts.layout(String(text), {
|
|
169
|
-
|
|
170
|
-
// labels these popups are sized around name no family of their own, so
|
|
171
|
-
// the one they are drawn in is the palette's. Measuring in a different
|
|
172
|
-
// face is measuring the wrong label — a menu sized in sans-serif for a
|
|
173
|
-
// row that paints in a wider mono is a menu whose own options wrap.
|
|
174
|
-
family: style?.family ?? node?.inheritedTextStyle?.family ?? 'sans-serif',
|
|
184
|
+
family: style?.family ?? inherited?.family ?? 'sans-serif',
|
|
175
185
|
size: size * s,
|
|
176
|
-
weight: style?.weight ?? 'normal',
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
variations: style?.variations,
|
|
186
|
+
weight: style?.weight ?? inherited?.weight ?? 'normal',
|
|
187
|
+
style: style?.style ?? inherited?.style,
|
|
188
|
+
variations: style?.variations ?? inherited?.variations,
|
|
180
189
|
});
|
|
181
190
|
return { width: layout.width / s, height: layout.height / s };
|
|
182
191
|
}
|
package/src/foreignnodes.js
CHANGED
|
@@ -340,6 +340,13 @@ export class ForeignNode extends Node {
|
|
|
340
340
|
this._syncGeometry();
|
|
341
341
|
}
|
|
342
342
|
|
|
343
|
+
// the scroll fast path moves `abs` without coming through absolutize
|
|
344
|
+
// (issue #405), and the embedded window has to follow it all the same
|
|
345
|
+
_shiftAbs(dx, dy) {
|
|
346
|
+
super._shiftAbs(dx, dy);
|
|
347
|
+
this._syncGeometry();
|
|
348
|
+
}
|
|
349
|
+
|
|
343
350
|
_syncGeometry() {
|
|
344
351
|
const socket = this.socket;
|
|
345
352
|
if (!socket) return;
|
package/src/glnodes.js
CHANGED
|
@@ -230,6 +230,13 @@ export class GlAreaNode extends Node {
|
|
|
230
230
|
this._syncGeometry();
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
+
// the scroll fast path moves `abs` without coming through absolutize
|
|
234
|
+
// (issue #405), and the real X window has to follow it all the same
|
|
235
|
+
_shiftAbs(dx, dy) {
|
|
236
|
+
super._shiftAbs(dx, dy);
|
|
237
|
+
this._syncGeometry();
|
|
238
|
+
}
|
|
239
|
+
|
|
233
240
|
_syncGeometry() {
|
|
234
241
|
const wnd = this.window;
|
|
235
242
|
if (!wnd) return;
|
package/src/nodes.js
CHANGED
|
@@ -3261,6 +3261,74 @@ export class Node {
|
|
|
3261
3261
|
}
|
|
3262
3262
|
}
|
|
3263
3263
|
|
|
3264
|
+
/**
|
|
3265
|
+
* Move an already-laid-out subtree by a constant, without asking yoga
|
|
3266
|
+
* anything — the scroll fast path's walk (issue #405).
|
|
3267
|
+
*
|
|
3268
|
+
* A pure-scroll frame changes nothing about the arrangement inside a
|
|
3269
|
+
* viewport: every descendant sits exactly where the last pass put it,
|
|
3270
|
+
* shifted by the scroll delta. `absolutize` would re-derive each rect
|
|
3271
|
+
* through four wasm-boundary getters to learn what one addition already
|
|
3272
|
+
* says, so the scroller calls this instead — only after proving nothing
|
|
3273
|
+
* inside was laid out this pass (see `_absolutizeChildren`).
|
|
3274
|
+
*
|
|
3275
|
+
* `abs` is adjusted in place rather than replaced: its identity is
|
|
3276
|
+
* already long-lived (`_assignAbs` keeps the object whenever a rect is
|
|
3277
|
+
* unchanged), and everything that records a rect for later copies it.
|
|
3278
|
+
* The cached hit bounds ride along instead of being dropped — a uniform
|
|
3279
|
+
* translation is the one change a cached union survives — which keeps a
|
|
3280
|
+
* wheel flick from rebuilding the pane's whole hit-bounds tree per notch.
|
|
3281
|
+
*
|
|
3282
|
+
* No layout diff runs here, and none is owed: under a blit ledger the
|
|
3283
|
+
* shifted diff's claims are the *deviations* from exactly this
|
|
3284
|
+
* translation, and a subtree nothing laid out again has none.
|
|
3285
|
+
*/
|
|
3286
|
+
_shiftAbs(dx, dy) {
|
|
3287
|
+
if (!this.yoga) return;
|
|
3288
|
+
const abs = this.abs;
|
|
3289
|
+
abs.x += dx;
|
|
3290
|
+
abs.y += dy;
|
|
3291
|
+
const b = this._hitBoundsCache;
|
|
3292
|
+
if (b) {
|
|
3293
|
+
b.left += dx;
|
|
3294
|
+
b.right += dx;
|
|
3295
|
+
b.top += dy;
|
|
3296
|
+
b.bottom += dy;
|
|
3297
|
+
}
|
|
3298
|
+
this._shiftChildren(dx, dy);
|
|
3299
|
+
}
|
|
3300
|
+
|
|
3301
|
+
/** Split from `_shiftAbs` so a scroller can reroute its children through
|
|
3302
|
+
* its own offset bookkeeping — the box moves rigidly, but the children's
|
|
3303
|
+
* origin also carries scroll offsets that may have changed again this
|
|
3304
|
+
* same frame (`Scrollable._shiftChildren`). */
|
|
3305
|
+
_shiftChildren(dx, dy) {
|
|
3306
|
+
for (const child of this.children) {
|
|
3307
|
+
if (!child.isWindow) child._shiftAbs(dx, dy);
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
|
|
3311
|
+
/**
|
|
3312
|
+
* A layout-affecting change at this node may change how far the content
|
|
3313
|
+
* of an enclosing scroll pane reaches through a route yoga never
|
|
3314
|
+
* witnesses — an element that paints its own content growing its extent
|
|
3315
|
+
* announces it with `invalidate(true, this, 'scroll')`
|
|
3316
|
+
* (docs/extending.md), and no yoga node is dirtied by that. Mark every
|
|
3317
|
+
* scroller whose measurement can see this node, so the next pass asks
|
|
3318
|
+
* `measureScrollContent` again instead of reusing the cached reach
|
|
3319
|
+
* (issue #405). The walk stops where the measurement does: at the first
|
|
3320
|
+
* ancestor that clips its children, whose overflow is its own business.
|
|
3321
|
+
*/
|
|
3322
|
+
_markScrollMeasureDirty() {
|
|
3323
|
+
for (let n = this; n; n = n.parent) {
|
|
3324
|
+
// only a Scrollable carries the flag; a stale `true` on a box that is
|
|
3325
|
+
// not currently a scroller costs nothing and re-measures correctly if
|
|
3326
|
+
// its style later makes it one
|
|
3327
|
+
if (n._scrollMeasureDirty === false) n._scrollMeasureDirty = true;
|
|
3328
|
+
if (n !== this && n.clipsChildren()) return;
|
|
3329
|
+
}
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3264
3332
|
/**
|
|
3265
3333
|
* Ask the owning window to repaint. The damage lives on the window node,
|
|
3266
3334
|
* which is the only node with a frame clock — this forwards there, so an
|
|
@@ -3274,6 +3342,9 @@ export class Node {
|
|
|
3274
3342
|
* the mount invalidates in full anyway.
|
|
3275
3343
|
*/
|
|
3276
3344
|
invalidate(layoutChanged = false, damage = null, reason = null) {
|
|
3345
|
+
// a layout change may grow what an enclosing scroll pane has to scroll,
|
|
3346
|
+
// through a route yoga never sees (issue #405)
|
|
3347
|
+
if (layoutChanged) this._markScrollMeasureDirty();
|
|
3277
3348
|
this.root?.invalidate(layoutChanged, damage, reason);
|
|
3278
3349
|
}
|
|
3279
3350
|
|
|
@@ -3429,6 +3500,7 @@ export class Node {
|
|
|
3429
3500
|
* FULL_DAMAGE the way a bare `invalidate(true, null)` would.
|
|
3430
3501
|
*/
|
|
3431
3502
|
_invalidateLayout(reason) {
|
|
3503
|
+
this._markScrollMeasureDirty();
|
|
3432
3504
|
const root = this.root;
|
|
3433
3505
|
if (!root) return;
|
|
3434
3506
|
// Same walk, same frame, same answer — see `_childListBefore`, whose
|
|
@@ -5279,6 +5351,10 @@ export const Scrollable = (Base) =>
|
|
|
5279
5351
|
this.scrollX = 0;
|
|
5280
5352
|
this.contentHeight = 0;
|
|
5281
5353
|
this.contentWidth = 0;
|
|
5354
|
+
// `measureScrollContent` owed a fresh answer — true until the first
|
|
5355
|
+
// layout pass measures, and re-raised by any change yoga cannot see
|
|
5356
|
+
// (`_markScrollMeasureDirty`, issue #405)
|
|
5357
|
+
this._scrollMeasureDirty = true;
|
|
5282
5358
|
}
|
|
5283
5359
|
|
|
5284
5360
|
/**
|
|
@@ -5301,6 +5377,9 @@ export const Scrollable = (Base) =>
|
|
|
5301
5377
|
* position the same way when a box stops scrolling.
|
|
5302
5378
|
*/
|
|
5303
5379
|
_overflowChanged() {
|
|
5380
|
+
// whichever way the style flipped, the next scrolling pass starts
|
|
5381
|
+
// from a fresh measurement
|
|
5382
|
+
this._scrollMeasureDirty = true;
|
|
5304
5383
|
if (this.isScroller()) return;
|
|
5305
5384
|
this._scrollIntoViewTarget = null;
|
|
5306
5385
|
this._childOrigin = null;
|
|
@@ -5335,20 +5414,39 @@ export const Scrollable = (Base) =>
|
|
|
5335
5414
|
return;
|
|
5336
5415
|
}
|
|
5337
5416
|
const rtl = this.direction === 'rtl';
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
|
|
5347
|
-
|
|
5348
|
-
|
|
5417
|
+
// A pure-scroll pass re-learns nothing by walking (issue #405): the
|
|
5418
|
+
// content reach and every child's place *inside* the pane only change
|
|
5419
|
+
// when layout inside the pane changes. Yoga's own has-new-layout flag
|
|
5420
|
+
// is the witness — consumed here and nowhere else — set by any pass
|
|
5421
|
+
// that laid this node or anything under it out again, and left clear
|
|
5422
|
+
// by one that merely scrolled. `_scrollMeasureDirty` covers the one
|
|
5423
|
+
// route yoga cannot see: an element that paints its own content
|
|
5424
|
+
// growing its extent (docs/extending.md), announced through
|
|
5425
|
+
// `invalidate(true, this, 'scroll')`. The root's yoga node re-flags
|
|
5426
|
+
// on every pass, so a `<window overflow='scroll'>` always takes the
|
|
5427
|
+
// full walk — the pane that holds an app's long list is a box.
|
|
5428
|
+
const clean =
|
|
5429
|
+
this._childOrigin != null &&
|
|
5430
|
+
!this._scrollMeasureDirty &&
|
|
5431
|
+
!this.yoga.hasNewLayout();
|
|
5432
|
+
if (!clean) {
|
|
5433
|
+
const size = this.measureScrollContent();
|
|
5434
|
+
if (!Number.isFinite(size?.width) || !Number.isFinite(size?.height)) {
|
|
5435
|
+
// A NaN here does not throw on its own: it becomes a NaN max
|
|
5436
|
+
// scroll, a NaN offset, and every child laid out at NaN — a whole
|
|
5437
|
+
// tree gone with nothing naming the element that did it.
|
|
5438
|
+
throw new Error(
|
|
5439
|
+
`react-x11: <${this.kind}>.measureScrollContent() must return ` +
|
|
5440
|
+
'{ width, height } as finite numbers; it returned ' +
|
|
5441
|
+
`${describeSize(size)}. Return { width: 0, height: 0 } for ` +
|
|
5442
|
+
'content that has not arrived yet.',
|
|
5443
|
+
);
|
|
5444
|
+
}
|
|
5445
|
+
this.contentWidth = size.width;
|
|
5446
|
+
this.contentHeight = size.height;
|
|
5447
|
+
this._scrollMeasureDirty = false;
|
|
5448
|
+
this.yoga.markLayoutSeen();
|
|
5349
5449
|
}
|
|
5350
|
-
this.contentWidth = size.width;
|
|
5351
|
-
this.contentHeight = size.height;
|
|
5352
5450
|
this._resolveScrollIntoView();
|
|
5353
5451
|
this.scrollY = clampScroll(this.scrollY, this._maxScroll('y'));
|
|
5354
5452
|
this.scrollX = clampScroll(this.scrollX, this._maxScroll('x'));
|
|
@@ -5373,6 +5471,23 @@ export const Scrollable = (Base) =>
|
|
|
5373
5471
|
const wasOrigin = this._childOrigin;
|
|
5374
5472
|
const shifted = wasOrigin && (wasOrigin.x !== ox || wasOrigin.y !== oy);
|
|
5375
5473
|
this._childOrigin = { x: ox, y: oy };
|
|
5474
|
+
if (clean) {
|
|
5475
|
+
// The fast path (issue #405): nothing inside was laid out, so every
|
|
5476
|
+
// child sits exactly where the last pass put it, shifted by however
|
|
5477
|
+
// far the origin moved — one uniform translation instead of a
|
|
5478
|
+
// per-node yoga re-derivation. The layout diff is owed nothing by
|
|
5479
|
+
// construction: under a blit ledger the shifted diff's claims are
|
|
5480
|
+
// the deviations from this very translation, and a clean pane has
|
|
5481
|
+
// none — the walk below lands every node where the diff would have
|
|
5482
|
+
// reported silence.
|
|
5483
|
+
if (!shifted) return;
|
|
5484
|
+
const dx = ox - wasOrigin.x;
|
|
5485
|
+
const dy = oy - wasOrigin.y;
|
|
5486
|
+
for (const child of this.children) {
|
|
5487
|
+
if (!child.isWindow) child._shiftAbs(dx, dy);
|
|
5488
|
+
}
|
|
5489
|
+
return;
|
|
5490
|
+
}
|
|
5376
5491
|
const outer = layoutDiffSink;
|
|
5377
5492
|
const outerShift = layoutDiffShift;
|
|
5378
5493
|
const ledger = shifted && this._blitLedgerOpen();
|
|
@@ -5416,6 +5531,22 @@ export const Scrollable = (Base) =>
|
|
|
5416
5531
|
}
|
|
5417
5532
|
}
|
|
5418
5533
|
|
|
5534
|
+
/**
|
|
5535
|
+
* A scroller inside a shifting subtree does not ride the translation
|
|
5536
|
+
* blindly: its box moves rigidly, but its children's origin also
|
|
5537
|
+
* carries the scroll offsets, which may have changed again this very
|
|
5538
|
+
* frame — a wheel on a nested pane while an outer one scrolls.
|
|
5539
|
+
* Re-entering `_absolutizeChildren` folds both into one delta, and
|
|
5540
|
+
* re-runs the gate, so a nested pane that is not clean still walks
|
|
5541
|
+
* properly. (Reached only under an outer pane's fast path, which
|
|
5542
|
+
* proved nothing in here was laid out — the nested gate can only
|
|
5543
|
+
* decline over its own `_scrollMeasureDirty`.)
|
|
5544
|
+
*/
|
|
5545
|
+
_shiftChildren(dx, dy) {
|
|
5546
|
+
if (!this.isScroller()) return super._shiftChildren(dx, dy);
|
|
5547
|
+
this._absolutizeChildren(this.abs.x, this.abs.y);
|
|
5548
|
+
}
|
|
5549
|
+
|
|
5419
5550
|
/**
|
|
5420
5551
|
* Tell the owner how big the viewport and the content turned out, when
|
|
5421
5552
|
* either changes. Layout happens on the frame clock, *after* the commit
|
|
@@ -5477,8 +5608,14 @@ export const Scrollable = (Base) =>
|
|
|
5477
5608
|
* wheel, the scrollbars, the scroll keys and the AT-SPI scroll pane all
|
|
5478
5609
|
* read the numbers this returns (docs/extending.md).
|
|
5479
5610
|
*
|
|
5480
|
-
* Called once per layout pass, from `absolutize`, so it may
|
|
5481
|
-
* geometry but must not invalidate or paint
|
|
5611
|
+
* Called at most once per layout pass, from `absolutize`, so it may
|
|
5612
|
+
* read yoga geometry but must not invalidate or paint — and cached
|
|
5613
|
+
* across passes that laid nothing inside the pane out again (issue
|
|
5614
|
+
* #405): a pass that merely scrolled reuses the last answer, since a
|
|
5615
|
+
* scroll cannot change how far the content reaches. An element whose
|
|
5616
|
+
* extent changed by a route layout never saw — rows arrived, a line
|
|
5617
|
+
* was typed — announces it with `invalidate(true, this, 'scroll')`,
|
|
5618
|
+
* and the next pass asks again.
|
|
5482
5619
|
*/
|
|
5483
5620
|
measureScrollContent() {
|
|
5484
5621
|
const rtl = this.direction === 'rtl';
|