@react-x11/components 0.10.0 → 0.12.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.
Files changed (155) hide show
  1. package/dist/code/index.d.ts.map +1 -1
  2. package/dist/code/index.js +25 -24
  3. package/dist/code/index.js.map +1 -1
  4. package/dist/code-language/index.d.ts +1 -1
  5. package/dist/code-language/index.d.ts.map +1 -1
  6. package/dist/code-language/index.js +1 -1
  7. package/dist/code-language/index.js.map +1 -1
  8. package/dist/code-language/runs.d.ts +31 -0
  9. package/dist/code-language/runs.d.ts.map +1 -1
  10. package/dist/code-language/runs.js +105 -0
  11. package/dist/code-language/runs.js.map +1 -1
  12. package/dist/codeblock/index.d.ts +13 -0
  13. package/dist/codeblock/index.d.ts.map +1 -1
  14. package/dist/codeblock/index.js +37 -1
  15. package/dist/codeblock/index.js.map +1 -1
  16. package/dist/flow/node.d.ts +14 -6
  17. package/dist/flow/node.d.ts.map +1 -1
  18. package/dist/flow/node.js +82 -64
  19. package/dist/flow/node.js.map +1 -1
  20. package/dist/html/controls.d.ts +26 -7
  21. package/dist/html/controls.d.ts.map +1 -1
  22. package/dist/html/controls.js +28 -3
  23. package/dist/html/controls.js.map +1 -1
  24. package/dist/html/css/cascade.d.ts +32 -10
  25. package/dist/html/css/cascade.d.ts.map +1 -1
  26. package/dist/html/css/cascade.js +89 -31
  27. package/dist/html/css/cascade.js.map +1 -1
  28. package/dist/html/css/color.d.ts +19 -0
  29. package/dist/html/css/color.d.ts.map +1 -1
  30. package/dist/html/css/color.js +86 -0
  31. package/dist/html/css/color.js.map +1 -1
  32. package/dist/html/css/parse.d.ts +11 -0
  33. package/dist/html/css/parse.d.ts.map +1 -1
  34. package/dist/html/css/parse.js +114 -4
  35. package/dist/html/css/parse.js.map +1 -1
  36. package/dist/html/css/style.d.ts +45 -5
  37. package/dist/html/css/style.d.ts.map +1 -1
  38. package/dist/html/css/style.js +363 -14
  39. package/dist/html/css/style.js.map +1 -1
  40. package/dist/html/css/ua.js +22 -5
  41. package/dist/html/css/ua.js.map +1 -1
  42. package/dist/html/css/values.d.ts +10 -1
  43. package/dist/html/css/values.d.ts.map +1 -1
  44. package/dist/html/css/values.js +27 -2
  45. package/dist/html/css/values.js.map +1 -1
  46. package/dist/html/css/vars.d.ts +5 -0
  47. package/dist/html/css/vars.d.ts.map +1 -1
  48. package/dist/html/css/vars.js +2 -2
  49. package/dist/html/css/vars.js.map +1 -1
  50. package/dist/html/dom.d.ts.map +1 -1
  51. package/dist/html/dom.js +35 -2
  52. package/dist/html/dom.js.map +1 -1
  53. package/dist/html/index.d.ts.map +1 -1
  54. package/dist/html/index.js +38 -1
  55. package/dist/html/index.js.map +1 -1
  56. package/dist/html/layout/block.d.ts +11 -2
  57. package/dist/html/layout/block.d.ts.map +1 -1
  58. package/dist/html/layout/block.js +306 -25
  59. package/dist/html/layout/block.js.map +1 -1
  60. package/dist/html/layout/boxes.d.ts +61 -6
  61. package/dist/html/layout/boxes.d.ts.map +1 -1
  62. package/dist/html/layout/boxes.js +65 -16
  63. package/dist/html/layout/boxes.js.map +1 -1
  64. package/dist/html/layout/cache.d.ts +2 -2
  65. package/dist/html/layout/cache.d.ts.map +1 -1
  66. package/dist/html/layout/cache.js +8 -3
  67. package/dist/html/layout/cache.js.map +1 -1
  68. package/dist/html/layout/floats.d.ts +4 -0
  69. package/dist/html/layout/floats.d.ts.map +1 -1
  70. package/dist/html/layout/floats.js +26 -0
  71. package/dist/html/layout/floats.js.map +1 -1
  72. package/dist/html/layout/inline.d.ts +29 -2
  73. package/dist/html/layout/inline.d.ts.map +1 -1
  74. package/dist/html/layout/inline.js +1454 -125
  75. package/dist/html/layout/inline.js.map +1 -1
  76. package/dist/html/node.d.ts +33 -0
  77. package/dist/html/node.d.ts.map +1 -1
  78. package/dist/html/node.js +608 -86
  79. package/dist/html/node.js.map +1 -1
  80. package/dist/html/paint.d.ts +53 -1
  81. package/dist/html/paint.d.ts.map +1 -1
  82. package/dist/html/paint.js +746 -214
  83. package/dist/html/paint.js.map +1 -1
  84. package/dist/html/surfaces.d.ts +7 -2
  85. package/dist/html/surfaces.d.ts.map +1 -1
  86. package/dist/html/surfaces.js +16 -0
  87. package/dist/html/surfaces.js.map +1 -1
  88. package/dist/html/svg.d.ts.map +1 -1
  89. package/dist/html/svg.js +27 -20
  90. package/dist/html/svg.js.map +1 -1
  91. package/dist/internal/codelines.d.ts +42 -0
  92. package/dist/internal/codelines.d.ts.map +1 -0
  93. package/dist/internal/codelines.js +170 -0
  94. package/dist/internal/codelines.js.map +1 -0
  95. package/dist/internal/window.d.ts +22 -0
  96. package/dist/internal/window.d.ts.map +1 -1
  97. package/dist/internal/window.js +35 -8
  98. package/dist/internal/window.js.map +1 -1
  99. package/dist/markdown/index.d.ts.map +1 -1
  100. package/dist/markdown/index.js +98 -26
  101. package/dist/markdown/index.js.map +1 -1
  102. package/dist/rich-text-editor/index.d.ts.map +1 -1
  103. package/dist/rich-text-editor/index.js +3 -8
  104. package/dist/rich-text-editor/index.js.map +1 -1
  105. package/dist/rich-text-editor/markdown.d.ts.map +1 -1
  106. package/dist/rich-text-editor/markdown.js +30 -1
  107. package/dist/rich-text-editor/markdown.js.map +1 -1
  108. package/dist/rich-text-editor/replace.d.ts +13 -0
  109. package/dist/rich-text-editor/replace.d.ts.map +1 -0
  110. package/dist/rich-text-editor/replace.js +64 -0
  111. package/dist/rich-text-editor/replace.js.map +1 -0
  112. package/dist/richtext/node.d.ts +7 -0
  113. package/dist/richtext/node.d.ts.map +1 -1
  114. package/dist/richtext/node.js.map +1 -1
  115. package/dist/terminal-output/index.d.ts.map +1 -1
  116. package/dist/terminal-output/index.js +100 -25
  117. package/dist/terminal-output/index.js.map +1 -1
  118. package/dist/terminal-output/runs.d.ts +4 -1
  119. package/dist/terminal-output/runs.d.ts.map +1 -1
  120. package/dist/terminal-output/runs.js +5 -1
  121. package/dist/terminal-output/runs.js.map +1 -1
  122. package/package.json +5 -3
  123. package/src/code/index.ts +34 -32
  124. package/src/code-language/index.ts +1 -1
  125. package/src/code-language/runs.ts +111 -1
  126. package/src/codeblock/index.ts +48 -1
  127. package/src/flow/node.ts +80 -64
  128. package/src/html/controls.ts +50 -9
  129. package/src/html/css/cascade.ts +134 -43
  130. package/src/html/css/color.ts +87 -0
  131. package/src/html/css/parse.ts +107 -4
  132. package/src/html/css/style.ts +386 -23
  133. package/src/html/css/ua.ts +22 -5
  134. package/src/html/css/values.ts +31 -3
  135. package/src/html/css/vars.ts +2 -2
  136. package/src/html/dom.ts +38 -2
  137. package/src/html/index.ts +39 -1
  138. package/src/html/layout/block.ts +360 -22
  139. package/src/html/layout/boxes.ts +98 -23
  140. package/src/html/layout/cache.ts +8 -3
  141. package/src/html/layout/floats.ts +24 -0
  142. package/src/html/layout/inline.ts +1690 -119
  143. package/src/html/node.ts +648 -101
  144. package/src/html/paint.ts +883 -283
  145. package/src/html/surfaces.ts +21 -1
  146. package/src/html/svg.ts +30 -19
  147. package/src/internal/codelines.ts +200 -0
  148. package/src/internal/window.ts +36 -8
  149. package/src/markdown/index.ts +145 -26
  150. package/src/rich-text-editor/index.ts +3 -11
  151. package/src/rich-text-editor/markdown.ts +36 -1
  152. package/src/rich-text-editor/replace.ts +85 -0
  153. package/src/richtext/node.ts +7 -0
  154. package/src/terminal-output/index.ts +127 -34
  155. package/src/terminal-output/runs.ts +16 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@react-x11/components",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Components for react-x11 that do not belong in the core package",
5
5
  "keywords": [
6
6
  "react",
@@ -164,6 +164,7 @@
164
164
  "docs": "npm --prefix website start",
165
165
  "docs:build": "npm --prefix website run build",
166
166
  "bench:zengarden": "node --import tsx scripts/zengarden/run.ts",
167
+ "bench:zengarden:scroll": "node --import tsx scripts/zengarden/scroll.tsx",
167
168
  "examples:browser": "node --import tsx examples/browser/browser.tsx",
168
169
  "examples:calendar": "node --import tsx examples/calendar.tsx",
169
170
  "examples:charts": "node --import tsx examples/charts.tsx",
@@ -198,6 +199,7 @@
198
199
  "prepack": "npm run build"
199
200
  },
200
201
  "dependencies": {
202
+ "bidi-js": "^1.0.3",
201
203
  "css-select": "^7.0.0",
202
204
  "domhandler": "^6.0.0",
203
205
  "domutils": "^4.0.0",
@@ -219,7 +221,7 @@
219
221
  "@lydell/node-pty": "^1.0.0",
220
222
  "node-pty": "^1.0.0",
221
223
  "react": "^19.0.0",
222
- "react-x11": "^2.23.0"
224
+ "react-x11": "^2.26.0"
223
225
  },
224
226
  "peerDependenciesMeta": {
225
227
  "@lezer/highlight": {
@@ -246,7 +248,7 @@
246
248
  "pngjs": "^7.0.0",
247
249
  "prettier": "^3.6.0",
248
250
  "react": "^19.2.8",
249
- "react-x11": "^2.23.0",
251
+ "react-x11": "^2.26.0",
250
252
  "tsx": "^4.23.1",
251
253
  "typescript": "^7.0.2",
252
254
  "y-prosemirror": "^1.3.7",
package/src/code/index.ts CHANGED
@@ -17,21 +17,18 @@ import type { ReactElement } from 'react';
17
17
  import { useTheme } from 'react-x11';
18
18
  import type { Style } from 'react-x11/style';
19
19
 
20
- import {
21
- registerRichText,
22
- RICHTEXT_ELEMENT,
23
- useSelectionMenu,
24
- } from '../richtext/index.js';
25
- import type { RichTextProps, TextRun } from '../richtext/index.js';
20
+ import { registerRichText, useSelectionMenu } from '../richtext/index.js';
21
+ import type { TextRun } from '../richtext/index.js';
26
22
  import {
27
23
  codeBlockLook,
28
- codeBlockRuns,
24
+ CodeBlockRunCache,
29
25
  codeBlockStyle,
30
26
  codeTextStyle,
31
27
  } from '../codeblock/index.js';
32
28
  import type { Language, TokenStyles } from '../code-language/index.js';
33
29
  import { hx } from './hx.js';
34
30
  import { useFontPrewarm } from '../internal/prewarm.js';
31
+ import { CodeGutter, CodeLines } from '../internal/codelines.js';
35
32
 
36
33
  const h = React.createElement;
37
34
 
@@ -107,39 +104,44 @@ export function Code(props: CodeProps): ReactElement {
107
104
  );
108
105
  useFontPrewarm(look.family);
109
106
 
107
+ // Kept between renders, so a source that changes at its end is tokenized
108
+ // again from the line that changed (`CodeBlockRunCache`).
109
+ const runCache = React.useRef<CodeBlockRunCache | null>(null);
110
+ runCache.current ??= new CodeBlockRunCache();
111
+ React.useEffect(() => () => runCache.current?.dispose(), []);
110
112
  const runs: TextRun[] = React.useMemo(
111
- () => codeBlockRuns(source, look, { lang, language, resolveLanguage }),
113
+ () =>
114
+ runCache.current!.runs(source, look, { lang, language, resolveLanguage }),
112
115
  [source, lang, language, resolveLanguage, look],
113
116
  );
114
117
 
115
118
  const lineCount = React.useMemo(() => source.split('\n').length, [source]);
116
119
 
117
- const codeProps: RichTextProps = { runs, style: codeTextStyle(look, wrap) };
118
- if (!wrap) codeProps.wrap = false;
120
+ // In blocks of lines, so a change lays out the blocks it touches
121
+ // (`../internal/codelines.ts`).
122
+ const code = h(CodeLines, { runs, style: codeTextStyle(look, wrap), wrap });
119
123
 
120
124
  const gutter = lineNumbers
121
- ? h(RICHTEXT_ELEMENT, {
122
- // one richtext for all the numbers: same font, same line height, so
123
- // it stays in register with the code beside it. `selectable={false}`
124
- // is what keeps it out of a drag and out of the copied text — the
125
- // numbering is chrome, and CSS spells that `user-select: none`.
126
- selectable: false,
127
- runs: Array.from({ length: lineCount }, (_, i) => ({
128
- text: `${i + 1}\n`,
129
- family: look.family,
130
- size: look.size,
131
- color: look.dim,
132
- })),
133
- wrap: false,
134
- style: {
135
- lineHeight: look.lineHeight,
136
- textAlign: 'right',
137
- minWidth:
138
- Math.max(String(lineCount).length, 2) * Math.ceil(look.size * 0.62),
139
- marginRight: look.padding,
140
- flexShrink: 0,
125
+ ? hx(
126
+ 'box',
127
+ {
128
+ style: {
129
+ flexDirection: 'column',
130
+ minWidth:
131
+ Math.max(String(lineCount).length, 2) *
132
+ Math.ceil(look.size * 0.62),
133
+ marginRight: look.padding,
134
+ flexShrink: 0,
135
+ },
141
136
  },
142
- } as Record<string, unknown>)
137
+ // numbers in the same font and line height as the code, blocked the
138
+ // same way, so the two stay in register
139
+ h(CodeGutter, {
140
+ lines: lineCount,
141
+ run: { family: look.family, size: look.size, color: look.dim },
142
+ style: { lineHeight: look.lineHeight, textAlign: 'right' },
143
+ }),
144
+ )
143
145
  : null;
144
146
 
145
147
  const rootStyle: Style = {
@@ -170,7 +172,7 @@ export function Code(props: CodeProps): ReactElement {
170
172
  hx(
171
173
  'box',
172
174
  { style: { overflow: 'scroll', flexDirection: 'column', flexGrow: 1 } },
173
- h(RICHTEXT_ELEMENT, codeProps as unknown as Record<string, unknown>),
175
+ code,
174
176
  ),
175
177
  );
176
178
  }
@@ -34,7 +34,7 @@ export {
34
34
  } from './theme.js';
35
35
 
36
36
  export { languageForTag, tokenizeText } from './registry.js';
37
- export { codeRuns } from './runs.js';
37
+ export { codeRuns, CodeRunCache } from './runs.js';
38
38
  export type { CodeRun, CodeRunOptions } from './runs.js';
39
39
 
40
40
  export { TOKEN_FALLBACK } from './types.js';
@@ -23,7 +23,7 @@
23
23
  // AGENTS.md on tree-shaking, and `textmate.ts` for the same shape.
24
24
  import { languageForTag, tokenizeText } from './registry.js';
25
25
  import { tokenStyleFor } from './theme.js';
26
- import type { Language, TokenStyles } from './types.js';
26
+ import type { Language, Token, Tokenizer, TokenStyles } from './types.js';
27
27
 
28
28
  /** One styled run of code. Structurally a `<richtext>` run. */
29
29
  export interface CodeRun {
@@ -126,3 +126,113 @@ function push(
126
126
  out.push(run);
127
127
  }
128
128
  }
129
+
130
+ /**
131
+ * `codeRuns`, kept between calls for a text that changes a little at a
132
+ * time: a fence being streamed, a source being appended to.
133
+ *
134
+ * One tokenizer lives as long as the cache. It is told which lines changed
135
+ * (`Tokenizer.edit`, the interface `<CodeEditor>` already drives) and asked
136
+ * again only for those and the lines after them its state has to walk
137
+ * through, and every other line keeps the runs it had. `codeRuns` tokenized
138
+ * the whole text afresh: at 5,000 lines, most of the time an appended line
139
+ * took on the client.
140
+ *
141
+ * The runs are the same text in the same colours `codeRuns` gives, cut a
142
+ * little differently: a line's runs never reach into the next, and each
143
+ * newline is a run of its own, so that a line keeps its runs while others
144
+ * change. `decorate` is applied to every run once, when it is made — the
145
+ * family and size a block of code is set in, say.
146
+ *
147
+ * Call `dispose()` when done with it: an engine may keep timers.
148
+ */
149
+ export class CodeRunCache<R = CodeRun> {
150
+ private key: readonly unknown[] | null = null;
151
+ private tokenizer: Tokenizer | null = null;
152
+ private lines: string[] = [];
153
+ private tokens: (readonly Token[] | undefined)[] = [];
154
+ private lineRuns: (R[] | undefined)[] = [];
155
+ private newline: R | null = null;
156
+
157
+ constructor(
158
+ private readonly decorate: (run: CodeRun) => R = (run) => run as R,
159
+ ) {}
160
+
161
+ runs(text: string, tag: string, opts: CodeRunOptions): R[] {
162
+ if (text.length === 0)
163
+ return [this.decorate({ text: '', color: opts.color })];
164
+ const language =
165
+ opts.language ??
166
+ (tag ? (opts.resolveLanguage?.(tag) ?? languageForTag(tag)) : null);
167
+ if (!language) return [this.decorate({ text, color: opts.color })];
168
+
169
+ const key = [language, opts.styles, opts.color, opts.resolveToken];
170
+ if (!this.key || key.some((part, i) => part !== this.key![i])) {
171
+ this.dispose();
172
+ this.key = key;
173
+ this.lines = [];
174
+ this.tokens = [];
175
+ this.lineRuns = [];
176
+ this.newline = this.decorate({ text: '\n', color: opts.color });
177
+ this.tokenizer = language.createTokenizer({ invalidate: () => {} });
178
+ this.tokenizer.setLines(this.lines);
179
+ }
180
+ const tokenizer = this.tokenizer!;
181
+
182
+ // the lines the text shares with the last one, from the top: the edit
183
+ // is everything after them
184
+ const next = text.split('\n');
185
+ const lines = this.lines;
186
+ const shared = Math.min(lines.length, next.length);
187
+ let from = 0;
188
+ while (from < shared && lines[from] === next[from]) from++;
189
+ if (from < lines.length || from < next.length) {
190
+ const removed = lines.length - from;
191
+ const inserted = next.length - from;
192
+ lines.length = from;
193
+ for (let i = from; i < next.length; i++) lines.push(next[i]!);
194
+ tokenizer.edit({ fromLine: from, removed, inserted });
195
+ this.tokens.length = from;
196
+ this.lineRuns.length = from;
197
+ }
198
+
199
+ // Asked in order from the top, so a stream engine walks its frontier
200
+ // line by line and answers each exactly, never from a guess.
201
+ const out: R[] = [];
202
+ for (let i = 0; i < lines.length; i++) {
203
+ if (i > 0) out.push(this.newline!);
204
+ const tokens = tokenizer.lineTokens(i);
205
+ let runs = this.lineRuns[i];
206
+ if (!runs || this.tokens[i] !== tokens) {
207
+ runs = lineRuns(lines[i]!, tokens, opts).map(this.decorate);
208
+ this.lineRuns[i] = runs;
209
+ this.tokens[i] = tokens;
210
+ }
211
+ for (const run of runs) out.push(run);
212
+ }
213
+ return out;
214
+ }
215
+
216
+ dispose(): void {
217
+ this.tokenizer?.dispose?.();
218
+ this.tokenizer = null;
219
+ this.key = null;
220
+ }
221
+ }
222
+
223
+ /** One line's runs, as `codeRuns` makes them, up to its end. */
224
+ function lineRuns(
225
+ line: string,
226
+ tokens: readonly Token[],
227
+ opts: CodeRunOptions,
228
+ ): CodeRun[] {
229
+ const out: CodeRun[] = [];
230
+ let at = 0;
231
+ for (const t of tokens) {
232
+ if (t.from > at) push(out, line.slice(at, t.from), undefined, opts);
233
+ push(out, line.slice(t.from, t.to), t.type, opts);
234
+ at = t.to;
235
+ }
236
+ if (at < line.length) push(out, line.slice(at), undefined, opts);
237
+ return out;
238
+ }
@@ -15,7 +15,11 @@
15
15
  // A shared module, not a component: no element registration, no React, and
16
16
  // nothing imported from either of its consumers (a component never imports
17
17
  // another component).
18
- import { codeRuns, autoTokenStyles } from '../code-language/index.js';
18
+ import {
19
+ codeRuns,
20
+ autoTokenStyles,
21
+ CodeRunCache,
22
+ } from '../code-language/index.js';
19
23
  import type { Language, TokenStyles } from '../code-language/index.js';
20
24
  import type { TextRun } from '../richtext/index.js';
21
25
  import { tint } from 'react-x11/style';
@@ -130,6 +134,49 @@ export function codeBlockRuns(
130
134
  }).map((run) => ({ ...run, family: look.family, size: look.size }));
131
135
  }
132
136
 
137
+ /**
138
+ * `codeBlockRuns` kept between calls, for a block of code that changes a
139
+ * little at a time — a fence being streamed, a source being appended to:
140
+ * the tokenizer is told which lines changed and the rest keep their runs
141
+ * (`CodeRunCache`). Call `dispose()` when done with it.
142
+ */
143
+ export class CodeBlockRunCache {
144
+ private cache: CodeRunCache<TextRun> | null = null;
145
+ private family: string | undefined;
146
+ private size: number | undefined;
147
+
148
+ runs(
149
+ source: string,
150
+ look: CodeBlockLook,
151
+ options: CodeBlockRunOptions = {},
152
+ ): TextRun[] {
153
+ if (!this.cache || this.family !== look.family || this.size !== look.size) {
154
+ this.cache?.dispose();
155
+ const { family, size } = look;
156
+ this.family = family;
157
+ this.size = size;
158
+ this.cache = new CodeRunCache<TextRun>((run) => ({
159
+ ...run,
160
+ family,
161
+ size,
162
+ }));
163
+ }
164
+ const { lang = '', language, resolveLanguage, highlight = true } = options;
165
+ return this.cache.runs(source, highlight ? lang : '', {
166
+ styles: look.styles,
167
+ color: look.color,
168
+ ...(highlight && language ? { language } : null),
169
+ ...(highlight && resolveLanguage ? { resolveLanguage } : null),
170
+ resolveToken: look.resolveToken,
171
+ });
172
+ }
173
+
174
+ dispose(): void {
175
+ this.cache?.dispose();
176
+ this.cache = null;
177
+ }
178
+ }
179
+
133
180
  /** The block's own box: fill, radius, padding. What surrounds it — a row
134
181
  * with a gutter, a column in a document — is the component's. */
135
182
  export function codeBlockStyle(look: CodeBlockLook): Style {
package/src/flow/node.ts CHANGED
@@ -215,8 +215,6 @@ interface PanelCanvas {
215
215
  * global that wandered in would become an implicit `@types/node` dependency
216
216
  * a consumer has to satisfy. */
217
217
  const timers = globalThis as {
218
- setInterval?(fn: () => void, ms: number): unknown;
219
- clearInterval?(id: unknown): void;
220
218
  setTimeout?(fn: () => void, ms: number): unknown;
221
219
  clearTimeout?(id: unknown): void;
222
220
  };
@@ -301,14 +299,28 @@ const VISUAL_PROPS = [
301
299
  const clock = globalThis as { performance?: { now(): number } };
302
300
 
303
301
  /**
304
- * The pane's time: what a pan is stamped with, and what the dashes' march
305
- * reads to decide it has held still. A test holds it (test/flow.test.ts):
306
- * a runner that spends longer than the dashes' wait over one step of a pan
307
- * lets them march in the middle of it — as they should — and a test of the
308
- * pan cannot tell that from a march it should not have made.
302
+ * The pane's time and the dashes' timer: what a pan is stamped with, what
303
+ * the dashes' march reads to decide it has held still, and what takes the
304
+ * march's steps. A test holds both (`holdClock` in test/held-clock.ts). On
305
+ * the real clock a runner that spends longer than the dashes' wait over one
306
+ * step of a pan lets them march in the middle of it, as they should, and
307
+ * one that stalls before the pan lets a tick claim the dashes the first step
308
+ * copies, which declines the copy. A test of the pan cannot tell either from
309
+ * a march it should not have made.
310
+ *
311
+ * The shape of the map's `glideClock`. Exported from this module and not
312
+ * from `./index.ts`: this is for the tests.
309
313
  */
310
314
  export const flowClock = {
311
- now: (): number => clock.performance?.now() ?? Date.now(),
315
+ now(): number {
316
+ return clock.performance?.now() ?? Date.now();
317
+ },
318
+ arm(step: () => void, ms: number): unknown {
319
+ return timers.setTimeout?.(step, ms) ?? null;
320
+ },
321
+ disarm(handle: unknown): void {
322
+ timers.clearTimeout?.(handle);
323
+ },
312
324
  };
313
325
  const now = (): number => flowClock.now();
314
326
 
@@ -3230,67 +3242,71 @@ export class FlowGraphNode extends Node implements FlowInstance {
3230
3242
 
3231
3243
  private _startAnimation(): void {
3232
3244
  if (this._animTimer != null) return;
3233
- this._animTimer =
3234
- timers.setInterval?.(() => {
3235
- // A pan that blits copies the pane's pixels, dashes and all, and a
3236
- // tick claims the dashes inside the band it copies — which declines
3237
- // the copy: every frame a tick landed in repainted the whole pane,
3238
- // a sixth of a pan's frames and all of its stutter. So the dashes
3239
- // sit the pan out, phase and all, and what the pan copies and what
3240
- // it draws agree; they march again once it has held still.
3241
- //
3242
- // And the view moving at all is what they wait out, not a blit that
3243
- // happened: a tick declines the blit it lands beside, so a pan
3244
- // whose frames ran past the wait had its ticks back, each one
3245
- // cancelling the next blit and repainting the pane — over the
3246
- // stress example's widgets in a large window, 2 frames a second,
3247
- // ticks and pan steps taking turns. A step since the last tick, or
3248
- // one within two ticks, holds the dashes still whatever a frame
3249
- // costs. Under GL a tick is a uniform and blits nothing, and they
3250
- // march through a pan as before.
3251
- //
3252
- // A 2D tick is a repaint of the box the dashes are in, which over a
3253
- // dense graph in a large window is most of the pane: 75 ms a tick on
3254
- // XQuartz, against a timer of 60. So the wait scales with what
3255
- // frames cost: the view held still for two frames' worth, and one
3256
- // tick every two frames at most — the dashes slow down before the
3257
- // thread is theirs, and a pan whose frames come slowly still sees
3258
- // them hold still.
3259
- const t = now();
3260
- const lastTick = this._tickAt;
3261
- this._tickAt = t;
3262
- if (t - this._blittedPanAt < ANIMATION_MS * 2) return;
3263
- let steps = 1;
3264
- if (!this._gl) {
3265
- const cost = this._tickCost * 2;
3266
- const still = Math.max(ANIMATION_MS * 2, cost);
3267
- if (this._viewMovedAt > lastTick || t - this._viewMovedAt < still) {
3268
- return;
3269
- }
3270
- // a tick under ANIMATION_MS apart, less a millisecond of timer
3271
- // slop; further while ticks are dear — and then the dashes cover
3272
- // the ground the skipped ticks would have, so they slow down in
3273
- // steps and not in speed
3274
- const since = t - this._marchedAt;
3275
- if (since < Math.max(ANIMATION_MS, cost) - 1) return;
3276
- steps = Math.min(4, Math.max(1, Math.round(since / ANIMATION_MS)));
3245
+ const tick = (): void => {
3246
+ // A step at a time rather than an interval, so that a test can hold
3247
+ // the timer (`flowClock`), and armed again before anything else,
3248
+ // which is when an interval would have been.
3249
+ this._animTimer = flowClock.arm(tick, ANIMATION_MS);
3250
+ // A pan that blits copies the pane's pixels, dashes and all, and a
3251
+ // tick claims the dashes inside the band it copies — which declines
3252
+ // the copy: every frame a tick landed in repainted the whole pane,
3253
+ // a sixth of a pan's frames and all of its stutter. So the dashes
3254
+ // sit the pan out, phase and all, and what the pan copies and what
3255
+ // it draws agree; they march again once it has held still.
3256
+ //
3257
+ // And the view moving at all is what they wait out, not a blit that
3258
+ // happened: a tick declines the blit it lands beside, so a pan
3259
+ // whose frames ran past the wait had its ticks back, each one
3260
+ // cancelling the next blit and repainting the pane — over the
3261
+ // stress example's widgets in a large window, 2 frames a second,
3262
+ // ticks and pan steps taking turns. A step since the last tick, or
3263
+ // one within two ticks, holds the dashes still whatever a frame
3264
+ // costs. Under GL a tick is a uniform and blits nothing, and they
3265
+ // march through a pan as before.
3266
+ //
3267
+ // A 2D tick is a repaint of the box the dashes are in, which over a
3268
+ // dense graph in a large window is most of the pane: 75 ms a tick on
3269
+ // XQuartz, against a timer of 60. So the wait scales with what
3270
+ // frames cost: the view held still for two frames' worth, and one
3271
+ // tick every two frames at most — the dashes slow down before the
3272
+ // thread is theirs, and a pan whose frames come slowly still sees
3273
+ // them hold still.
3274
+ const t = now();
3275
+ const lastTick = this._tickAt;
3276
+ this._tickAt = t;
3277
+ if (t - this._blittedPanAt < ANIMATION_MS * 2) return;
3278
+ let steps = 1;
3279
+ if (!this._gl) {
3280
+ const cost = this._tickCost * 2;
3281
+ const still = Math.max(ANIMATION_MS * 2, cost);
3282
+ if (this._viewMovedAt > lastTick || t - this._viewMovedAt < still) {
3283
+ return;
3277
3284
  }
3278
- this._marchedAt = t;
3279
- this._dashPhase += ANIMATION_SPEED * steps;
3280
- // the box the last paint saw animated edges in, not the pane: a
3281
- // marching dash should not cost a full grid repaint per tick. And
3282
- // only the part of it the pane shows — an edge on its way out of
3283
- // the pane takes the box past it, and claimed whole, a tick
3284
- // repainted everything the window has beside the graph.
3285
- const box = this._animBox ? this._device(this._animBox) : this.abs;
3286
- const shown = intersectRects(box, this.contentBox());
3287
- if (shown) this.invalidate(false, shown, 'animation');
3288
- }, ANIMATION_MS) ?? null;
3285
+ // a tick under ANIMATION_MS apart, less a millisecond of timer
3286
+ // slop; further while ticks are dear — and then the dashes cover
3287
+ // the ground the skipped ticks would have, so they slow down in
3288
+ // steps and not in speed
3289
+ const since = t - this._marchedAt;
3290
+ if (since < Math.max(ANIMATION_MS, cost) - 1) return;
3291
+ steps = Math.min(4, Math.max(1, Math.round(since / ANIMATION_MS)));
3292
+ }
3293
+ this._marchedAt = t;
3294
+ this._dashPhase += ANIMATION_SPEED * steps;
3295
+ // the box the last paint saw animated edges in, not the pane: a
3296
+ // marching dash should not cost a full grid repaint per tick. And
3297
+ // only the part of it the pane shows — an edge on its way out of
3298
+ // the pane takes the box past it, and claimed whole, a tick
3299
+ // repainted everything the window has beside the graph.
3300
+ const box = this._animBox ? this._device(this._animBox) : this.abs;
3301
+ const shown = intersectRects(box, this.contentBox());
3302
+ if (shown) this.invalidate(false, shown, 'animation');
3303
+ };
3304
+ this._animTimer = flowClock.arm(tick, ANIMATION_MS);
3289
3305
  }
3290
3306
 
3291
3307
  private _stopAnimation(): void {
3292
3308
  if (this._animTimer == null) return;
3293
- timers.clearInterval?.(this._animTimer);
3309
+ flowClock.disarm(this._animTimer);
3294
3310
  this._animTimer = null;
3295
3311
  }
3296
3312
 
@@ -24,7 +24,7 @@ import type { Element } from 'domhandler';
24
24
  import { attr, tagOf } from './dom.js';
25
25
  import type { ComputedStyle } from './css/style.js';
26
26
  import { isTransparent } from './css/values.js';
27
- import type { BoxTree, ReplacedKind } from './layout/boxes.js';
27
+ import type { Box, BoxTree, ReplacedKind } from './layout/boxes.js';
28
28
  import type { FontsLike } from './layout/inline.js';
29
29
 
30
30
  /** The palette numbers a control's box has to reserve room for. */
@@ -48,15 +48,24 @@ export interface ControlRect {
48
48
  width: number;
49
49
  height: number;
50
50
  /**
51
- * Set on a text field whose own box the author styled — gave it a border
52
- * or a background (`styledField`). The document draws that box, and the
53
- * widget goes bare inside its content box, here, with no frame or fill
54
- * of its own and its text in the element's colour and font.
51
+ * Set on a text field or a `<select>` whose own box the author styled —
52
+ * gave it a border or a background (`styledField`). The document draws
53
+ * that box, and the widget goes bare inside its content box, here, with
54
+ * no frame or fill of its own and its text in the element's colour and
55
+ * font.
55
56
  */
56
57
  bare?: BareField;
58
+ /**
59
+ * How opaque the widget is drawn, where it is less than 1: the element's
60
+ * own `opacity` times every ancestor's, since each fades what is in it as
61
+ * a group. At 0 the widget is not seen and still takes a press, as the
62
+ * element does in a browser — the checkbox a CSS-only dropdown lays,
63
+ * invisible, over its label.
64
+ */
65
+ opacity?: number;
57
66
  }
58
67
 
59
- /** Where a styled text field's widget goes, and how its text looks. */
68
+ /** Where a styled field's widget goes, and how its text looks. */
60
69
  export interface BareField {
61
70
  x: number;
62
71
  y: number;
@@ -65,6 +74,12 @@ export interface BareField {
65
74
  color: string;
66
75
  fontFamily: string;
67
76
  fontSize: number;
77
+ /**
78
+ * A `<select>`'s: whether it draws its arrow. It does unless the page set
79
+ * `appearance: none`, which is how a page that draws its own arrow — as a
80
+ * background image, most often — says so.
81
+ */
82
+ chevron?: boolean;
68
83
  }
69
84
 
70
85
  /** The rectangles every control in a laid-out document landed on. */
@@ -73,6 +88,8 @@ export function controlRectsOf(tree: BoxTree): ControlRect[] {
73
88
  for (const box of tree.controls) {
74
89
  if (!box.el) continue;
75
90
  if (box.width <= 0 || box.height <= 0) continue;
91
+ // a hidden element draws nothing and takes no press (CSS 2.1 11.2)
92
+ if (box.style.visibility !== 'visible') continue;
76
93
  const rect: ControlRect = {
77
94
  element: box.el,
78
95
  kind: box.replaced,
@@ -81,6 +98,11 @@ export function controlRectsOf(tree: BoxTree): ControlRect[] {
81
98
  width: box.width,
82
99
  height: box.height,
83
100
  };
101
+ let opacity = 1;
102
+ for (let at: Box | null = box; at; at = at.parent) {
103
+ opacity *= at.style.opacity;
104
+ }
105
+ if (opacity < 1) rect.opacity = Math.max(0, opacity);
84
106
  if (styledField(box.replaced, box.style)) {
85
107
  rect.bare = {
86
108
  x: box.contentX,
@@ -90,6 +112,9 @@ export function controlRectsOf(tree: BoxTree): ControlRect[] {
90
112
  color: box.style.color,
91
113
  fontFamily: box.style.fontFamily,
92
114
  fontSize: box.style.fontSize,
115
+ ...(box.replaced === 'select' && {
116
+ chevron: box.style.appearance !== 'none',
117
+ }),
93
118
  };
94
119
  }
95
120
  out.push(rect);
@@ -98,17 +123,23 @@ export function controlRectsOf(tree: BoxTree): ControlRect[] {
98
123
  }
99
124
 
100
125
  /**
101
- * Whether a text field's box is the author's to draw: one given a border or
102
- * a background of its own, which the UA sheet gives no control, or set to
126
+ * Whether a field's box is the author's to draw: one given a border or a
127
+ * background of its own, which the UA sheet gives no control, or set to
103
128
  * `appearance: none`, which says so outright. A browser drops a field's
104
129
  * native look for the author's then (CSS UI 4 7.1, `appearance`), and so
105
130
  * does this: the widget's frame and fill would hide the author's, and what
106
131
  * they would draw is the theme's rather than the page's. `appearance: none`
107
132
  * is how a design system writes every field it has — meetup.com's search
108
133
  * pill holds two with no background, only one of them with a border.
134
+ *
135
+ * A `<select>` is one too. A browser keeps its arrow when the page gave it a
136
+ * border or a background (Blink's `menulist-button`), and leaves that out
137
+ * as well at `appearance: none` — `BareField.chevron`.
109
138
  */
110
139
  export function styledField(kind: ReplacedKind, style: ComputedStyle): boolean {
111
- if (kind !== 'input' && kind !== 'textarea') return false;
140
+ if (kind !== 'input' && kind !== 'textarea' && kind !== 'select') {
141
+ return false;
142
+ }
112
143
  return (
113
144
  style.appearance === 'none' ||
114
145
  !isTransparent(style.backgroundColor) ||
@@ -168,6 +199,16 @@ export function measureControl(
168
199
  }
169
200
  case 'select': {
170
201
  const widest = optionWidths(el);
202
+ // a select the page styled is its widest option and its arrow, and
203
+ // the room around them is the page's
204
+ if (styledField(kind, style)) {
205
+ return {
206
+ width: Math.round(
207
+ ch * widest + (style.appearance === 'none' ? 0 : em),
208
+ ),
209
+ height: lineHeight,
210
+ };
211
+ }
171
212
  // The room for the chevron is the widget's, not the document's, but
172
213
  // the document has to reserve it or the last letter of the widest
173
214
  // option sits under it.