react-x11 2.15.3 → 2.16.1

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 (67) hide show
  1. package/README.md +37 -0
  2. package/package.json +3 -3
  3. package/src/Reconciler.js +85 -22
  4. package/src/acceleratorhooks.js +40 -6
  5. package/src/anchor.js +79 -19
  6. package/src/capabilities.js +29 -4
  7. package/src/cocoa/app.js +211 -11
  8. package/src/cocoa/context2d.js +23 -0
  9. package/src/cocoa/fonts.js +78 -0
  10. package/src/cocoa/presenter.js +17 -0
  11. package/src/cocoa/promotion.js +20 -0
  12. package/src/cocoa/relaunch.js +8 -3
  13. package/src/cocoa/symbols.js +64 -0
  14. package/src/cocoa/threaded.js +24 -4
  15. package/src/cocoa/window.js +362 -139
  16. package/src/components/ProgressBar.js +1 -1
  17. package/src/components/Slider.js +72 -39
  18. package/src/components/anchor.js +7 -2
  19. package/src/components/index.js +1 -0
  20. package/src/components/theme.js +32 -28
  21. package/src/desktopcapabilityhooks.js +29 -6
  22. package/src/filedialoghooks.js +3 -5
  23. package/src/frame/childmain.js +8 -20
  24. package/src/frame/env.js +2 -10
  25. package/src/icontheme.js +240 -0
  26. package/src/imagesource.js +83 -1
  27. package/src/index.d.ts +10 -1
  28. package/src/index.js +3 -0
  29. package/src/keysymchars.js +47 -0
  30. package/src/keysyms.d.ts +19 -1
  31. package/src/keysyms.js +107 -8
  32. package/src/node.d.ts +7 -0
  33. package/src/nodes/animation.js +17 -47
  34. package/src/nodes/cascade.js +17 -2
  35. package/src/nodes/image.js +63 -1
  36. package/src/nodes/kinds.js +12 -0
  37. package/src/nodes/layout.js +5 -1
  38. package/src/nodes/node.js +17 -3
  39. package/src/nodes/paint.js +117 -0
  40. package/src/nodes/scope.js +259 -0
  41. package/src/nodes/scrollable.js +53 -6
  42. package/src/nodes/text.js +2 -0
  43. package/src/nodes/textarea.js +1 -1
  44. package/src/nodes/textinput.js +1 -1
  45. package/src/nodes/window/anchoring.js +45 -18
  46. package/src/nodes/window/flush.js +6 -5
  47. package/src/nodes/window/popup.js +10 -0
  48. package/src/nodes/window/size.js +40 -2
  49. package/src/nodes/window/window.js +41 -14
  50. package/src/registry.js +2 -1
  51. package/src/screens.js +159 -24
  52. package/src/settings.js +332 -0
  53. package/src/statusnotifier.js +164 -17
  54. package/src/styles.js +212 -8
  55. package/src/symbols.js +200 -0
  56. package/src/testing/mock-app.js +10 -0
  57. package/src/trayhooks.js +21 -5
  58. package/src/types/capabilities.d.ts +13 -1
  59. package/src/types/components.d.ts +33 -0
  60. package/src/types/elements.d.ts +57 -6
  61. package/src/types/events.d.ts +5 -0
  62. package/src/types/filedialog.d.ts +3 -1
  63. package/src/types/style.d.ts +57 -0
  64. package/src/types/system.d.ts +104 -0
  65. package/src/types/tray.d.ts +14 -2
  66. package/src/wayland/xkb.js +170 -59
  67. package/src/windowid.js +62 -20
@@ -0,0 +1,240 @@
1
+ // The freedesktop icon theme: an icon found by name the way the Icon Theme
2
+ // Specification finds it, for a symbol drawn inside a window on Linux (#591).
3
+ //
4
+ // A theme is a directory of the same name under any of the base directories
5
+ // — `~/.icons`, `$XDG_DATA_HOME/icons`, each `$XDG_DATA_DIRS/icons` — with an
6
+ // `index.theme` saying which of its subdirectories hold which sizes and which
7
+ // themes it inherits from. A lookup tries the user's theme, then everything
8
+ // it inherits, then `hicolor`, the theme every other one falls back on, and
9
+ // last the loose files in `/usr/share/pixmaps`. Inside a theme it takes the
10
+ // first directory whose size matches, and failing that the closest one.
11
+ //
12
+ // The directories are listed rather than probed file by file: one `readdir`
13
+ // per directory, once, answers every name looked up there afterwards, where
14
+ // a `stat` per name per directory per theme is thousands of calls for a
15
+ // toolbar. Both run synchronously, since a layout pass cannot wait — the
16
+ // first lookup in a theme pays for the listing, and later ones are a `Map`.
17
+
18
+ import * as nodeFs from 'node:fs';
19
+ import { homedir } from 'node:os';
20
+ import { join } from 'node:path';
21
+
22
+ /** The formats a lookup answers with, in the specification's order. XPM is
23
+ * left out: nothing here decodes it. */
24
+ const EXTENSIONS = ['png', 'svg'];
25
+
26
+ /** Every base directory the specification searches, in its order. */
27
+ export function iconBaseDirs(env = process.env, home = homedir()) {
28
+ const dataHome = env.XDG_DATA_HOME || join(home, '.local', 'share');
29
+ const dataDirs = (env.XDG_DATA_DIRS || '/usr/local/share:/usr/share')
30
+ .split(':')
31
+ .filter(Boolean);
32
+ return [
33
+ join(home, '.icons'),
34
+ join(dataHome, 'icons'),
35
+ ...dataDirs.map((dir) => join(dir, 'icons')),
36
+ ];
37
+ }
38
+
39
+ /** Where loose icons with no theme live, searched last. */
40
+ export const PIXMAP_DIRS = ['/usr/share/pixmaps'];
41
+
42
+ /**
43
+ * `index.theme`'s keys, section by section — the desktop-entry format, which
44
+ * is an INI file with `#` comments. Values stay strings.
45
+ */
46
+ export function parseIndexTheme(text) {
47
+ const sections = new Map();
48
+ let current = null;
49
+ for (const raw of String(text).split(/\r?\n/)) {
50
+ const line = raw.trim();
51
+ if (!line || line.startsWith('#')) continue;
52
+ const header = /^\[(.+)\]$/.exec(line);
53
+ if (header) {
54
+ current = new Map();
55
+ sections.set(header[1], current);
56
+ continue;
57
+ }
58
+ const eq = line.indexOf('=');
59
+ if (current && eq > 0) {
60
+ current.set(line.slice(0, eq).trim(), line.slice(eq + 1).trim());
61
+ }
62
+ }
63
+ return sections;
64
+ }
65
+
66
+ const list = (value) =>
67
+ (value ?? '')
68
+ .split(',')
69
+ .map((s) => s.trim())
70
+ .filter(Boolean);
71
+
72
+ const int = (value, fallback) => {
73
+ const n = Number.parseInt(value, 10);
74
+ return Number.isFinite(n) ? n : fallback;
75
+ };
76
+
77
+ /** One subdirectory's size rules, with the specification's defaults. */
78
+ function directoryRules(keys) {
79
+ const size = int(keys?.get('Size'), 0);
80
+ return {
81
+ size,
82
+ scale: int(keys?.get('Scale'), 1),
83
+ type: keys?.get('Type') ?? 'Threshold',
84
+ minSize: int(keys?.get('MinSize'), size),
85
+ maxSize: int(keys?.get('MaxSize'), size),
86
+ threshold: int(keys?.get('Threshold'), 2),
87
+ };
88
+ }
89
+
90
+ /** The specification's DirectoryMatchesSize. */
91
+ function matchesSize(dir, size, scale) {
92
+ if (dir.scale !== scale) return false;
93
+ if (dir.type === 'Fixed') return dir.size === size;
94
+ if (dir.type === 'Scalable')
95
+ return dir.minSize <= size && size <= dir.maxSize;
96
+ return dir.size - dir.threshold <= size && size <= dir.size + dir.threshold;
97
+ }
98
+
99
+ /** The specification's DirectorySizeDistance, with its Threshold branch's
100
+ * typos read as what they mean: the threshold's bounds, times the scale. */
101
+ function sizeDistance(dir, size, scale) {
102
+ const want = size * scale;
103
+ if (dir.type === 'Fixed') return Math.abs(dir.size * dir.scale - want);
104
+ const low = dir.type === 'Scalable' ? dir.minSize : dir.size - dir.threshold;
105
+ const high = dir.type === 'Scalable' ? dir.maxSize : dir.size + dir.threshold;
106
+ if (want < low * dir.scale) return low * dir.scale - want;
107
+ if (want > high * dir.scale) return want - high * dir.scale;
108
+ return 0;
109
+ }
110
+
111
+ /**
112
+ * Icon lookup in one theme and everything under it. `find(name, size,
113
+ * scale)` answers an absolute path, or null.
114
+ */
115
+ export class IconTheme {
116
+ constructor({
117
+ theme = 'hicolor',
118
+ baseDirs = iconBaseDirs(),
119
+ pixmapDirs = PIXMAP_DIRS,
120
+ fs = nodeFs,
121
+ } = {}) {
122
+ this.theme = theme;
123
+ this.baseDirs = baseDirs;
124
+ this.pixmapDirs = pixmapDirs;
125
+ this.fs = fs;
126
+ this._themes = new Map(); // name -> { dirs, inherits } | null
127
+ this._listings = new Map(); // absolute dir -> Set of file names | null
128
+ this._found = new Map(); // name|size|scale -> path | null
129
+ }
130
+
131
+ find(name, size, scale = 1) {
132
+ const key = `${name}\u0000${size}\u0000${scale}`;
133
+ if (this._found.has(key)) return this._found.get(key);
134
+ const path =
135
+ this._inTheme(name, size, scale, this.theme, new Set()) ??
136
+ (this.theme === 'hicolor'
137
+ ? null
138
+ : this._inTheme(name, size, scale, 'hicolor', new Set())) ??
139
+ this._loose(name);
140
+ this._found.set(key, path);
141
+ return path;
142
+ }
143
+
144
+ /** FindIconHelper: this theme, then the ones it inherits, depth first. */
145
+ _inTheme(name, size, scale, theme, seen) {
146
+ if (seen.has(theme)) return null;
147
+ seen.add(theme);
148
+ const index = this._index(theme);
149
+ if (!index) return null;
150
+ const found = this._lookup(name, size, scale, theme, index);
151
+ if (found) return found;
152
+ for (const parent of index.inherits) {
153
+ const inherited = this._inTheme(name, size, scale, parent, seen);
154
+ if (inherited) return inherited;
155
+ }
156
+ return null;
157
+ }
158
+
159
+ /** LookupIcon: a directory of the right size, else the closest one. */
160
+ _lookup(name, size, scale, theme, index) {
161
+ let closest = null;
162
+ let distance = Infinity;
163
+ for (const dir of index.dirs) {
164
+ const fits = matchesSize(dir.rules, size, scale);
165
+ const d = fits ? 0 : sizeDistance(dir.rules, size, scale);
166
+ if (!fits && d >= distance) continue;
167
+ for (const base of this.baseDirs) {
168
+ const at = join(base, theme, dir.path);
169
+ const file = this._fileIn(at, name);
170
+ if (!file) continue;
171
+ if (fits) return file;
172
+ closest = file;
173
+ distance = d;
174
+ break;
175
+ }
176
+ }
177
+ return closest;
178
+ }
179
+
180
+ /** The loose files: an icon with no theme at all. */
181
+ _loose(name) {
182
+ for (const dir of this.pixmapDirs) {
183
+ const file = this._fileIn(dir, name);
184
+ if (file) return file;
185
+ }
186
+ return null;
187
+ }
188
+
189
+ _fileIn(dir, name) {
190
+ const names = this._listing(dir);
191
+ if (!names) return null;
192
+ for (const ext of EXTENSIONS) {
193
+ const file = `${name}.${ext}`;
194
+ if (names.has(file)) return join(dir, file);
195
+ }
196
+ return null;
197
+ }
198
+
199
+ _listing(dir) {
200
+ if (this._listings.has(dir)) return this._listings.get(dir);
201
+ let names = null;
202
+ try {
203
+ names = new Set(this.fs.readdirSync(dir));
204
+ } catch {
205
+ // not there, which is most directories in most base directories
206
+ }
207
+ this._listings.set(dir, names);
208
+ return names;
209
+ }
210
+
211
+ /** A theme's `index.theme`, from the first base directory that has one. */
212
+ _index(theme) {
213
+ if (this._themes.has(theme)) return this._themes.get(theme);
214
+ let index = null;
215
+ for (const base of this.baseDirs) {
216
+ let text;
217
+ try {
218
+ text = this.fs.readFileSync(join(base, theme, 'index.theme'), 'utf8');
219
+ } catch {
220
+ continue;
221
+ }
222
+ const sections = parseIndexTheme(text);
223
+ const head = sections.get('Icon Theme');
224
+ const names = [
225
+ ...list(head?.get('Directories')),
226
+ ...list(head?.get('ScaledDirectories')),
227
+ ];
228
+ index = {
229
+ inherits: list(head?.get('Inherits')),
230
+ dirs: [...new Set(names)].map((path) => ({
231
+ path,
232
+ rules: directoryRules(sections.get(path)),
233
+ })),
234
+ };
235
+ break;
236
+ }
237
+ this._themes.set(theme, index);
238
+ return index;
239
+ }
240
+ }
@@ -44,6 +44,73 @@ export function isPathImageSource(src) {
44
44
  export const toLoadablePath = (src) =>
45
45
  typeof src === 'string' || src instanceof URL ? src : new URL(src.href);
46
46
 
47
+ /**
48
+ * A symbol by name: `{ symbol, weight?, scale?, variableValue? }` — the
49
+ * platform's own icons, drawn in the text colour (#591, src/symbols.js). Not
50
+ * pixels at all, so none of the decoding or caching below applies: the node
51
+ * asks the platform to draw the name at paint.
52
+ */
53
+ export function isSymbolImageSource(src) {
54
+ return (
55
+ src != null &&
56
+ typeof src === 'object' &&
57
+ !(src instanceof Uint8Array) &&
58
+ 'symbol' in src
59
+ );
60
+ }
61
+
62
+ const SYMBOL_SCALES = new Set(['small', 'medium', 'large']);
63
+
64
+ function validateSymbolSource(src) {
65
+ const at = `react-x11: <image src={{ symbol: ${JSON.stringify(src.symbol)} }}>`;
66
+ if (typeof src.symbol !== 'string' || src.symbol === '') {
67
+ throw new Error(
68
+ `${at} needs a name: an SF Symbol on macOS, like 'speaker.wave.3.fill', ` +
69
+ "or an icon theme's name on Linux, like 'audio-volume-high'.",
70
+ );
71
+ }
72
+ const { weight, scale, variableValue } = src;
73
+ if (
74
+ weight !== undefined &&
75
+ !(
76
+ weight === 'normal' ||
77
+ weight === 'bold' ||
78
+ (typeof weight === 'number' && weight >= 1 && weight <= 1000)
79
+ )
80
+ ) {
81
+ throw new Error(
82
+ `${at} has weight ${JSON.stringify(weight)}, expected what fontWeight ` +
83
+ "takes — 'normal', 'bold' or a number from 1 to 1000.",
84
+ );
85
+ }
86
+ if (scale !== undefined && !SYMBOL_SCALES.has(scale)) {
87
+ throw new Error(
88
+ `${at} has scale ${JSON.stringify(scale)}, expected 'small', 'medium' ` +
89
+ "or 'large'.",
90
+ );
91
+ }
92
+ if (
93
+ variableValue !== undefined &&
94
+ !(
95
+ typeof variableValue === 'number' &&
96
+ variableValue >= 0 &&
97
+ variableValue <= 1
98
+ )
99
+ ) {
100
+ throw new Error(
101
+ `${at} has variableValue ${JSON.stringify(variableValue)}, expected a ` +
102
+ 'number from 0 to 1.',
103
+ );
104
+ }
105
+ }
106
+
107
+ /** Two symbol sources naming the same drawing, however fresh the objects. */
108
+ const sameSymbol = (a, b) =>
109
+ a.symbol === b.symbol &&
110
+ a.weight === b.weight &&
111
+ a.scale === b.scale &&
112
+ a.variableValue === b.variableValue;
113
+
47
114
  /** Raw straight-RGBA pixels: `{ width, height, data }` — the shape
48
115
  * `getImageData` hands back. (A `Buffer` of encoded bytes is a `Uint8Array`
49
116
  * subclass, so the two byte forms are one check elsewhere.) */
@@ -54,6 +121,7 @@ export function isRawImageSource(src) {
54
121
  !(src instanceof Uint8Array) &&
55
122
  !isPathImageSource(src) &&
56
123
  !isDirectImageSource(src) &&
124
+ !isSymbolImageSource(src) &&
57
125
  'data' in src
58
126
  );
59
127
  }
@@ -70,7 +138,8 @@ const describe = (value) =>
70
138
  /** Stated once, so every error lists the same set of accepted forms. */
71
139
  const SRC_FORMS =
72
140
  'a file path or file URL (PNG/JPEG), encoded PNG/JPEG bytes (Buffer or ' +
73
- 'Uint8Array), raw RGBA ({ width, height, data }), or an ntk Image/Surface';
141
+ 'Uint8Array), raw RGBA ({ width, height, data }), an ntk Image/Surface, ' +
142
+ "or a symbol by name ({ symbol: 'speaker.wave.3.fill' })";
74
143
 
75
144
  function validateServerSource(kind, desc) {
76
145
  const shape =
@@ -163,6 +232,16 @@ export function validateImageProps(props) {
163
232
  if (props.drawable != null) validateServerSource('drawable', props.drawable);
164
233
  const src = props.src;
165
234
  if (src == null) return;
235
+ if (isSymbolImageSource(src)) {
236
+ if (props.cacheKey != null) {
237
+ throw new Error(
238
+ 'react-x11: <image cacheKey> names decoded pixels, and a symbol is ' +
239
+ 'drawn by name, not decoded — there is nothing to cache. Drop the ' +
240
+ 'cacheKey.',
241
+ );
242
+ }
243
+ return validateSymbolSource(src);
244
+ }
166
245
  if (isPathImageSource(src)) return;
167
246
  if (src instanceof Uint8Array) return;
168
247
  if (isDirectImageSource(src)) return;
@@ -207,6 +286,9 @@ export function imageSourceChanged(next, prev) {
207
286
  if (next.cacheKey !== prev.cacheKey) return true;
208
287
  if (next.src === prev.src) return false;
209
288
  if ((next.src == null) !== (prev.src == null)) return true;
289
+ if (isSymbolImageSource(next.src) && isSymbolImageSource(prev.src)) {
290
+ return !sameSymbol(next.src, prev.src);
291
+ }
210
292
  if (isDirectImageSource(next.src) || isDirectImageSource(prev.src))
211
293
  return true;
212
294
  return next.cacheKey == null;
package/src/index.d.ts CHANGED
@@ -233,7 +233,15 @@ export interface RootOptions {
233
233
  * `resizeWait` is how long, in ms, AppKit may hold a live-resize tick for
234
234
  * the app's frame at the new size under `react-x11/cocoa-main`, where
235
235
  * the frame is painted on another thread (50 by default; 0 lets the edge
236
- * move without waiting). `appName` is what the Dock, ⌘-Tab and the app menu print for
236
+ * move without waiting).
237
+ * `screenPoll` is how often the screen layout is re-read while a
238
+ * {@link useScreens} subscriber is mounted, in ms — 500 by default, 0 for
239
+ * never. macOS has no event for a display plugged in, unplugged or
240
+ * rearranged that reaches a client, so a component watching the layout is
241
+ * kept current by asking; an app that never calls `useScreens` never
242
+ * polls, and the paths where a stale layout would misplace a window ask
243
+ * for themselves whatever this says.
244
+ * `appName` is what the Dock, ⌘-Tab and the app menu print for
237
245
  * an unbundled process (a bundle's Info.plist wins); `activationPolicy`
238
246
  * is `'regular'` (a Dock tile, a ⌘-Tab entry — the default),
239
247
  * `'accessory'` (a menu-bar app: windows but no tile) or `'prohibited'`,
@@ -253,6 +261,7 @@ export interface RootOptions {
253
261
  frameInterval?: number;
254
262
  pumpInterval?: number;
255
263
  resizeWait?: number;
264
+ screenPoll?: number;
256
265
  appName?: string;
257
266
  activationPolicy?: 'regular' | 'accessory' | 'prohibited';
258
267
  exitOnQuit?: boolean;
package/src/index.js CHANGED
@@ -93,6 +93,8 @@ export { useKeyboardState } from './keyboardstatehooks.js';
93
93
  export { matchesShortcut } from './accelerators.js';
94
94
  export { useAccelerator } from './acceleratorhooks.js';
95
95
  export { useDesktopSettings } from './desktopsettingshooks.js';
96
+ // what the app itself remembers between launches (#592)
97
+ export { createSettings } from './settings.js';
96
98
  export { loadFont, openFont } from './fonts.js';
97
99
  export { useFont } from './fonthooks.js';
98
100
  export { systemLocale } from './locale.js';
@@ -127,6 +129,7 @@ export {
127
129
  useAnchorTracking,
128
130
  anchorArea,
129
131
  anchorRect,
132
+ anchorScreenRect,
130
133
  centerRect,
131
134
  screenRect,
132
135
  } from './components/index.js';
@@ -0,0 +1,47 @@
1
+ // Generated by scripts/keysym-chars.mjs from X11's keysymdef.h — do not edit.
2
+ //
3
+ // The character each legacy keysym produces, run-length encoded: a run is
4
+ // `<keysym>:<codePoint>` in hex, or `<keysym>+<n>:<codePoint>` where the
5
+ // next n keysyms carry the next n code points. `charOf` (src/keysyms.js)
6
+ // expands it once, on first use. One line per keysymdef.h block, because that
7
+ // is how the header is organised and how a regeneration reads as a diff.
8
+ //
9
+ // Latin-1 and the Unicode keysym form are rules rather than entries and are
10
+ // not in here; neither is any keysym whose character is a control character,
11
+ // which is a key that types nothing.
12
+ export const KEYSYM_CHAR_RUNS = [
13
+ // Latin-2 (0x01xx), 57 keysyms
14
+ '1a1:104 1a2:2d8 1a3:141 1a5:13d 1a6:15a 1a9:160 1aa:15e 1ab:164 1ac:179 1ae:17d 1af:17b 1b1:105 1b2:2db 1b3:142 1b5:13e 1b6:15b 1b7:2c7 1b9:161 1ba:15f 1bb:165 1bc:17a 1bd:2dd 1be:17e 1bf:17c 1c0:154 1c3:102 1c5:139 1c6:106 1c8:10c 1ca:118 1cc:11a 1cf:10e 1d0:110 1d1:143 1d2:147 1d5:150 1d8:158 1d9:16e 1db:170 1de:162 1e0:155 1e3:103 1e5:13a 1e6:107 1e8:10d 1ea:119 1ec:11b 1ef:10f 1f0:111 1f1:144 1f2:148 1f5:151 1f8:159 1f9:16f 1fb:171 1fe:163 1ff:2d9',
15
+ // Latin-3 (0x02xx), 22 keysyms
16
+ '2a1:126 2a6:124 2a9:130 2ab:11e 2ac:134 2b1:127 2b6:125 2b9:131 2bb:11f 2bc:135 2c5:10a 2c6:108 2d5:120 2d8:11c 2dd:16c 2de:15c 2e5:10b 2e6:109 2f5:121 2f8:11d 2fd:16d 2fe:15d',
17
+ // Latin-4 (0x03xx), 35 keysyms
18
+ '3a2:138 3a3:156 3a5:128 3a6:13b 3aa:112 3ab:122 3ac:166 3b3:157 3b5:129 3b6:13c 3ba:113 3bb:123 3bc:167 3bd:14a 3bf:14b 3c0:100 3c7:12e 3cc:116 3cf:12a 3d1:145 3d2:14c 3d3:136 3d9:172 3dd:168 3de:16a 3e0:101 3e7:12f 3ec:117 3ef:12b 3f1:146 3f2:14d 3f3:137 3f9:173 3fd:169 3fe:16b',
19
+ // Katakana (0x04xx), 64 keysyms
20
+ '47e:203e 4a1:3002 4a2+1:300c 4a4:3001 4a5:30fb 4a6:30f2 4a7:30a1 4a8:30a3 4a9:30a5 4aa:30a7 4ab:30a9 4ac:30e3 4ad:30e5 4ae:30e7 4af:30c3 4b0:30fc 4b1:30a2 4b2:30a4 4b3:30a6 4b4:30a8 4b5+1:30aa 4b7:30ad 4b8:30af 4b9:30b1 4ba:30b3 4bb:30b5 4bc:30b7 4bd:30b9 4be:30bb 4bf:30bd 4c0:30bf 4c1:30c1 4c2:30c4 4c3:30c6 4c4:30c8 4c5+5:30ca 4cb:30d2 4cc:30d5 4cd:30d8 4ce:30db 4cf+4:30de 4d4:30e4 4d5:30e6 4d6+5:30e8 4dc:30ef 4dd:30f3 4de+1:309b',
21
+ // Arabic (0x05xx), 48 keysyms
22
+ '5ac:60c 5bb:61b 5bf:61f 5c1+25:621 5e0+18:640',
23
+ // Cyrillic (0x06xx), 95 keysyms
24
+ '6a1+1:452 6a3:451 6a4+8:454 6ad:491 6ae+1:45e 6b0:2116 6b1+1:402 6b3:401 6b4+8:404 6bd:490 6be+1:40e 6c0:44e 6c1+1:430 6c3:446 6c4+1:434 6c6:444 6c7:433 6c8:445 6c9+7:438 6d1:44f 6d2+3:440 6d6:436 6d7:432 6d8:44c 6d9:44b 6da:437 6db:448 6dc:44d 6dd:449 6de:447 6df:44a 6e0:42e 6e1+1:410 6e3:426 6e4+1:414 6e6:424 6e7:413 6e8:425 6e9+7:418 6f1:42f 6f2+3:420 6f6:416 6f7:412 6f8:42c 6f9:42b 6fa:417 6fb:428 6fc:42d 6fd:429 6fe:427 6ff:42a',
25
+ // Greek (0x07xx), 71 keysyms
26
+ '7a1:386 7a2+2:388 7a5:3aa 7a7:38c 7a8:38e 7a9:3ab 7ab:38f 7ae:385 7af:2015 7b1+3:3ac 7b5:3ca 7b6:390 7b7+1:3cc 7b9:3cb 7ba:3b0 7bb:3ce 7c1+16:391 7d2:3a3 7d4+5:3a4 7e1+16:3b1 7f2:3c3 7f3:3c2 7f4+5:3c4',
27
+ // Technical (0x08xx), 42 keysyms
28
+ '8a1:23b7 8a2:250c 8a3:2500 8a4+1:2320 8a6:2502 8a7:23a1 8a8+1:23a3 8aa:23a6 8ab:239b 8ac+1:239d 8ae:23a0 8af:23a8 8b0:23ac 8bc:2264 8bd:2260 8be:2265 8bf:222b 8c0:2234 8c1+1:221d 8c5:2207 8c8:223c 8c9:2243 8cd:21d4 8ce:21d2 8cf:2261 8d6:221a 8da+1:2282 8dc+1:2229 8de+1:2227 8ef:2202 8f6:192 8fb+3:2190',
29
+ // Special (0x09xx), 23 keysyms
30
+ '9e0:25c6 9e1:2592 9e2:2409 9e3+1:240c 9e5:240a 9e8:2424 9e9:240b 9ea:2518 9eb:2510 9ec:250c 9ed:2514 9ee:253c 9ef+1:23ba 9f1:2500 9f2+1:23bc 9f4:251c 9f5:2524 9f6:2534 9f7:252c 9f8:2502',
31
+ // Publishing (0x0axx), 80 keysyms
32
+ 'aa1:2003 aa2:2002 aa3+1:2004 aa5+3:2007 aa9:2014 aaa:2013 aac:2423 aae:2026 aaf:2025 ab0+7:2153 ab8:2105 abb:2012 abc:27e8 abd:2e abe:27e9 ac3+3:215b ac9:2122 aca:2613 acc:25c1 acd:25b7 ace:25cb acf:25af ad0+1:2018 ad2+1:201c ad4:211e ad5:2030 ad6+1:2032 ad9:271d adb:25ac adc:25c0 add:25b6 ade:25cf adf:25ae ae0:25e6 ae1:25ab ae2:25ad ae3:25b3 ae4:25bd ae5:2606 ae6:2022 ae7:25aa ae8:25b2 ae9:25bc aea:261c aeb:261e aec:2663 aed:2666 aee:2665 af0:2720 af1+1:2020 af3:2713 af4:2717 af5:266f af6:266d af7:2642 af8:2640 af9:260e afa:2315 afb:2117 afc:2038 afd:201a afe:201e',
33
+ // APL (0x0bxx), 19 keysyms
34
+ 'ba3:3c ba6:3e ba8:2228 ba9:2227 bc0:af bc2:22a4 bc3:2229 bc4:230a bc6:5f bca:2218 bcc:2395 bce:22a5 bcf:25cb bd3:2308 bd6:222a bd8:2283 bda:2282 bdc:22a3 bfc:22a2',
35
+ // Hebrew (0x0cxx), 28 keysyms
36
+ 'cdf:2017 ce0+26:5d0',
37
+ // Thai (0x0dxx), 84 keysyms
38
+ 'da1+57:e01 dde+15:e3e df0+9:e50',
39
+ // Korean (0x0exx), 91 keysyms
40
+ 'ea1+50:3131 ed4+26:11a8 eef:316d ef0:3171 ef1:3178 ef2:317f ef3:3181 ef4:3184 ef5:3186 ef6+1:318d ef8:11eb ef9:11f0 efa:11f9 eff:20a9',
41
+ // Latin-8 and Latin-9 (0x13xx), 3 keysyms
42
+ '13bc+1:152 13be:178',
43
+ // Currency (0x20xx), 1 keysyms
44
+ '20ac:20ac',
45
+ // the keypad (0xffxx), 18 keysyms
46
+ 'ff80:20 ffaa+15:2a ffbd:3d',
47
+ ];
package/src/keysyms.d.ts CHANGED
@@ -9,9 +9,27 @@
9
9
  */
10
10
  export function keysymOf(char: string): number;
11
11
 
12
- /** The character a keysym produces, or `''` for a non-printing key. */
12
+ /**
13
+ * The character a keysym produces, or `''` for a key that types nothing — a
14
+ * modifier, a function key, an arrow, or a dead key waiting for the letter it
15
+ * decorates. Latin-1 and the Unicode form are rules; the legacy blocks a real
16
+ * keymap is written in — Cyrillic, Greek, Latin-2/3/4, Arabic, Hebrew, Thai,
17
+ * the keypad, `EuroSign` — come out of a generated table.
18
+ */
13
19
  export function charOf(keysym: number): string;
14
20
 
21
+ /**
22
+ * The uppercase of a keysym, answered in the spelling the keysym was written
23
+ * in: `й` (`0x6ca`) uppercases to `Й` (`0x6ea`) and not to the Unicode-form
24
+ * spelling of the same letter. A keysym with no case comes back unchanged.
25
+ * This is how Caps Lock capitalises.
26
+ *
27
+ * `'ß'.toUpperCase()` is `'SS'` — two characters, where a key has one to give
28
+ * — so the first code point is what comes back: `S`. Same for `fi` and the
29
+ * polytonic Greek letters whose uppercase is a sequence.
30
+ */
31
+ export function keysymToUpper(keysym: number): number;
32
+
15
33
  /**
16
34
  * The letter of a Ctrl chord, independent of Shift — the keysym for its
17
35
  * lowercase form, so `keysymOf('z')` matches both Ctrl+Z and Ctrl+Shift+Z.
package/src/keysyms.js CHANGED
@@ -5,13 +5,28 @@
5
5
  // `ev.keysym`. The full X11 set is several thousand names; this is the part
6
6
  // a GUI actually handles, plus the rule for everything else.
7
7
  //
8
- // Two facts make the long tail unnecessary:
8
+ // Two rules cover most of it:
9
9
  //
10
10
  // - **Latin-1 is identity.** For U+0020 to U+00FF the keysym *is* the code
11
11
  // point, so `'a'` is `0x61` and `'é'` is `0xe9`. That is the whole ASCII
12
12
  // and Latin-1 range, no table needed.
13
- // - **Everything else is `0x01000000 + codePoint`.** That is the Unicode
14
- // keysym rule, and `keysymOf` below applies both.
13
+ // - **The Unicode form is `0x01000000 + codePoint`.** That is what `keysymOf`
14
+ // below produces for everything outside Latin-1.
15
+ //
16
+ // What the two rules do *not* cover is the **legacy keysym blocks**, and a
17
+ // real keymap is written in them: Cyrillic is `0x6xx`, Greek `0x7xx`,
18
+ // Latin-2/3/4 `0x1xx`–`0x3xx`, Hebrew `0x8xx`, Arabic `0x5xx`, `EuroSign` is
19
+ // `0x20ac` and the keypad digits are `0xffbx`. Those are a table, and it is
20
+ // `src/keysymchars.js` — generated from X11's `keysymdef.h`, which is where
21
+ // libxkbcommon's own table comes from (`scripts/keysym-chars.mjs`).
22
+ //
23
+ // It is only `charOf` that needs it. On X11 ntk supplies the code point and
24
+ // this function is never reached; the Wayland backend decodes the keymap
25
+ // itself (`src/wayland/xkb.js`) and is the first caller that needs `charOf`
26
+ // to be complete, which is why a Russian, Greek or Czech layout typed
27
+ // nothing at all and AltGr+E produced no Euro sign.
28
+
29
+ import { KEYSYM_CHAR_RUNS } from './keysymchars.js';
15
30
 
16
31
  /** The keysym for a single character, by the two rules above. */
17
32
  export function keysymOf(char) {
@@ -21,13 +36,97 @@ export function keysymOf(char) {
21
36
  return 0x01000000 + code;
22
37
  }
23
38
 
24
- /** The character a keysym produces, or `''` for a non-printing key. */
39
+ /**
40
+ * The legacy blocks, expanded once on first use — keysym -> code point, and
41
+ * the way back. The reverse direction is what keeps a case map inside the
42
+ * block it started in: `keysymToUpper(Cyrillic_shorti)` is `Cyrillic_SHORTI`
43
+ * and not the Unicode-form spelling of the same letter, which is the answer
44
+ * a keymap's own keysyms can be compared against.
45
+ */
46
+ let legacyChars;
47
+ let legacyKeysyms;
48
+ function expandLegacy() {
49
+ if (legacyChars) return;
50
+ legacyChars = new Map();
51
+ legacyKeysyms = new Map();
52
+ for (const line of KEYSYM_CHAR_RUNS)
53
+ for (const run of line.split(' ')) {
54
+ const [keysyms, cp] = run.split(':');
55
+ const [first, span] = keysyms.split('+');
56
+ const from = parseInt(first, 16);
57
+ const to = parseInt(cp, 16);
58
+ for (let i = 0; i <= (span ? +span : 0); i++) {
59
+ legacyChars.set(from + i, to + i);
60
+ // The eleven code points two blocks both spell — box drawing, a few
61
+ // set operators, `.` — keep the first, so the answer is stable.
62
+ if (!legacyKeysyms.has(to + i)) legacyKeysyms.set(to + i, from + i);
63
+ }
64
+ }
65
+ }
66
+ function legacyChar(keysym) {
67
+ expandLegacy();
68
+ return legacyChars.get(keysym);
69
+ }
70
+
71
+ /**
72
+ * The character a keysym produces, or `''` for a key that types nothing —
73
+ * a modifier, a function key, an arrow, or a dead key waiting for the letter
74
+ * it decorates.
75
+ *
76
+ * "Types nothing" includes the keys whose code point is a control character:
77
+ * `keysymdef.h` gives BackSpace U+0008 and Delete U+007F, and a text field
78
+ * that inserted those would be inserting a control byte rather than deleting
79
+ * anything. The same goes for the unassigned 0x7f–0x9f stretch of the
80
+ * Latin-1 range, which is not a keysym at all.
81
+ */
25
82
  export function charOf(keysym) {
26
- if (keysym >= 0x20 && keysym <= 0xff) return String.fromCodePoint(keysym);
27
- if (keysym >= 0x01000100 && keysym <= 0x0110ffff) {
28
- return String.fromCodePoint(keysym - 0x01000000);
83
+ if (keysym >= 0x20 && keysym <= 0x7e) return String.fromCodePoint(keysym);
84
+ if (keysym >= 0xa0 && keysym <= 0xff) return String.fromCodePoint(keysym);
85
+ if (keysym >= 0x01000000 && keysym <= 0x0110ffff) {
86
+ const cp = keysym - 0x01000000;
87
+ return cp >= 0x20 && cp !== 0x7f ? String.fromCodePoint(cp) : '';
88
+ }
89
+ const cp = legacyChar(keysym);
90
+ return cp === undefined ? '' : String.fromCodePoint(cp);
91
+ }
92
+
93
+ /**
94
+ * The uppercase of a keysym, staying in the block it came from — `й`
95
+ * (`0x6ca`) uppercases to `Й` (`0x6ea`) and not to the Unicode-form spelling
96
+ * of the same letter. A keysym with no case comes back unchanged.
97
+ *
98
+ * This is how Caps Lock capitalises. It is not a lookup of an uppercase
99
+ * sibling level on the same key: French AZERTY's `é` key is `[é, 2, ~, ˘]`,
100
+ * where level 2 is a digit, and German's AltGr `ſ` has no sibling at all —
101
+ * there is no key anywhere with `ſ` and `S` next to each other.
102
+ *
103
+ * `'ß'.toUpperCase()` is **`'SS'`**, two characters, and a keyboard has one
104
+ * key's worth of character to answer with, so the first code point is what
105
+ * comes back: `S`. libxkbcommon's narrower table says `ẞ` (U+1E9E) there, and
106
+ * the same goes for `ΐ` and `ΰ`, which it leaves alone. Those three are the
107
+ * whole of the disagreement, and `S` is the more useful of the two answers
108
+ * for a key that is about to insert a character.
109
+ */
110
+ export function keysymToUpper(keysym) {
111
+ const ch = charOf(keysym);
112
+ if (!ch) return keysym;
113
+ const upper = ch.toUpperCase();
114
+ if (upper === ch) return keysym;
115
+ const cp = upper.codePointAt(0);
116
+ if (cp === ch.codePointAt(0)) return keysym;
117
+ expandLegacy();
118
+ // Answer in the spelling the keysym was written in. A keymap that uses the
119
+ // legacy blocks gets a legacy keysym back — `й` is `Й` (`0x6ea`), not the
120
+ // Unicode-form spelling of the same letter — and so does Latin-1, where
121
+ // AltGr's `µ` uppercases to `Greek_MU`. A keysym already written in the
122
+ // Unicode form keeps it, because that is the block *it* chose. The two
123
+ // spell the same character either way; what differs is whether the answer
124
+ // can be compared against the keysyms the keymap itself carries.
125
+ if (keysym < 0x01000000) {
126
+ const legacy = legacyKeysyms.get(cp);
127
+ if (legacy !== undefined) return legacy;
29
128
  }
30
- return '';
129
+ return keysymOf(String.fromCodePoint(cp));
31
130
  }
32
131
 
33
132
  // --- editing and navigation ------------------------------------------------
package/src/node.d.ts CHANGED
@@ -236,6 +236,13 @@ export interface TextStyle {
236
236
  variations: Record<string, number> | undefined;
237
237
  textRendering: TextRendering | undefined;
238
238
  color: string;
239
+ /** `letterSpacing`, in device pixels like `size` — undefined unless a
240
+ * style or the cascade above it named one. */
241
+ letterSpacing: number | undefined;
242
+ /** The OpenType features `fontVariantNumeric` and `fontFeatureSettings`
243
+ * resolve to, tag → 1 on, 0 off or an alternate — undefined unless either
244
+ * was named. The same object for the same pair of values. */
245
+ features: Readonly<Record<string, number>> | undefined;
239
246
  }
240
247
 
241
248
  /**