react-x11 2.38.0 → 2.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
1
+ // `filter` on the native 2d context: canvas's own property, a CSS
2
+ // `<filter-value-list>` (Filter Effects 1), and what this context makes of
3
+ // it — the colour functions, each a matrix over straight RGB with an
4
+ // offset that leaves alpha as it is, and `opacity()`, which scales alpha.
5
+ //
6
+ // They are the functions the context can apply to everything it draws.
7
+ // Each leaves alpha alone, so on premultiplied colour each is a linear map
8
+ // that commutes with source-over: a fill, a stroke, a gradient's stops, a
9
+ // glyph run's ink and a symbol's colour are filtered by filtering the colour
10
+ // they are drawn in, exactly. An image and a surface are filtered pixel by
11
+ // pixel, and a text layout that carries colours of its own on a surface of
12
+ // its own (src/backend/context2d.js). `blur()`, `drop-shadow()` and a
13
+ // `url()` reach past what is drawn, which a colour cannot say; this context
14
+ // does not apply them, so a list with one in it does not stick, and a
15
+ // caller knows from reading the property back.
16
+
17
+ /** A function's amount where none is written: all of the effect for the
18
+ * ones whose argument is how much of it to take, and none for the others
19
+ * (Filter Effects 1, 13). */
20
+ const AMOUNTS = {
21
+ grayscale: 1,
22
+ sepia: 1,
23
+ invert: 1,
24
+ opacity: 1,
25
+ saturate: 1,
26
+ brightness: 1,
27
+ contrast: 1,
28
+ };
29
+
30
+ /** The functions this context applies; any other leaves a list unapplied. */
31
+ const COLOUR = new Set([...Object.keys(AMOUNTS), 'hue-rotate']);
32
+
33
+ const ANGLE_UNITS = {
34
+ deg: 1,
35
+ grad: 0.9,
36
+ rad: 180 / Math.PI,
37
+ turn: 360,
38
+ };
39
+
40
+ /**
41
+ * A filter list as this context applies it: `{ matrices, alpha }`, the
42
+ * colour matrices in the order written, each over straight RGB in 0–1 with
43
+ * an offset — none for a function that changes no colour — and what alpha
44
+ * is multiplied by. Null for `none`; undefined for what is no filter list,
45
+ * or one with a function this context does not apply.
46
+ */
47
+ export function parseCanvasFilter(value) {
48
+ const text = String(value).trim();
49
+ if (text.toLowerCase() === 'none') return null;
50
+ const matrices = [];
51
+ let alpha = 1;
52
+ let rest = text;
53
+ let seen = 0;
54
+ while (rest.length) {
55
+ const m = /^([a-z-]+)\(([^()]*)\)\s*/i.exec(rest);
56
+ if (!m) return undefined;
57
+ rest = rest.slice(m[0].length);
58
+ seen += 1;
59
+ const name = m[1].toLowerCase();
60
+ if (!COLOUR.has(name)) return undefined;
61
+ const arg = m[2].trim();
62
+ if (name === 'hue-rotate') {
63
+ const angle = arg ? angleOf(arg) : 0;
64
+ if (angle === null) return undefined;
65
+ const matrix = hueRotate(angle);
66
+ if (matrix) matrices.push(matrix);
67
+ continue;
68
+ }
69
+ const amount = arg ? amountOf(arg) : AMOUNTS[name];
70
+ if (amount === null || amount < 0) return undefined;
71
+ if (name === 'opacity') {
72
+ alpha *= Math.min(1, amount);
73
+ continue;
74
+ }
75
+ const matrix = matrixOf(name, amount);
76
+ if (matrix) matrices.push(matrix);
77
+ }
78
+ if (!seen) return undefined;
79
+ return { matrices, alpha };
80
+ }
81
+
82
+ /** Whether a parsed filter changes anything drawn. */
83
+ export function filters(filter) {
84
+ return !!filter && (filter.matrices.length > 0 || filter.alpha < 1);
85
+ }
86
+
87
+ /** A number or a percentage of one. */
88
+ function amountOf(arg) {
89
+ const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)(%?)$/i.exec(arg);
90
+ if (!m) return null;
91
+ const n = Number(m[1]);
92
+ if (!Number.isFinite(n)) return null;
93
+ return m[2] ? n / 100 : n;
94
+ }
95
+
96
+ /** An angle in degrees; a bare 0 is one. */
97
+ function angleOf(arg) {
98
+ const m = /^([+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?)([a-z]*)$/i.exec(arg);
99
+ if (!m) return null;
100
+ const n = Number(m[1]);
101
+ const unit = m[2].toLowerCase();
102
+ if (!unit) return n === 0 ? 0 : null;
103
+ const scale = ANGLE_UNITS[unit];
104
+ return scale === undefined ? null : n * scale;
105
+ }
106
+
107
+ const unit = (a) => (a < 0 ? 0 : a > 1 ? 1 : a);
108
+
109
+ /** Three rows of red, green and blue weights and an offset, 0–1. */
110
+ function rows(r, g, b) {
111
+ return Float64Array.of(...r, ...g, ...b);
112
+ }
113
+
114
+ /** `feComponentTransfer`'s `linear`: each channel times a slope, plus an
115
+ * intercept. */
116
+ function linear(slope, intercept) {
117
+ return Float64Array.of(
118
+ slope,
119
+ 0,
120
+ 0,
121
+ intercept,
122
+ 0,
123
+ slope,
124
+ 0,
125
+ intercept,
126
+ 0,
127
+ 0,
128
+ slope,
129
+ intercept,
130
+ );
131
+ }
132
+
133
+ /** One function's matrix (Filter Effects 1, 13); null where it changes no
134
+ * colour. */
135
+ function matrixOf(name, amount) {
136
+ switch (name) {
137
+ case 'grayscale': {
138
+ const a = unit(amount);
139
+ if (a === 0) return null;
140
+ const k = 1 - a;
141
+ return rows(
142
+ [0.2126 + 0.7874 * k, 0.7152 - 0.7152 * k, 0.0722 - 0.0722 * k, 0],
143
+ [0.2126 - 0.2126 * k, 0.7152 + 0.2848 * k, 0.0722 - 0.0722 * k, 0],
144
+ [0.2126 - 0.2126 * k, 0.7152 - 0.7152 * k, 0.0722 + 0.9278 * k, 0],
145
+ );
146
+ }
147
+ case 'sepia': {
148
+ const a = unit(amount);
149
+ if (a === 0) return null;
150
+ const k = 1 - a;
151
+ return rows(
152
+ [0.393 + 0.607 * k, 0.769 - 0.769 * k, 0.189 - 0.189 * k, 0],
153
+ [0.349 - 0.349 * k, 0.686 + 0.314 * k, 0.168 - 0.168 * k, 0],
154
+ [0.272 - 0.272 * k, 0.534 - 0.534 * k, 0.131 + 0.869 * k, 0],
155
+ );
156
+ }
157
+ case 'saturate': {
158
+ const s = amount;
159
+ if (s === 1) return null;
160
+ return rows(
161
+ [0.213 + 0.787 * s, 0.715 - 0.715 * s, 0.072 - 0.072 * s, 0],
162
+ [0.213 - 0.213 * s, 0.715 + 0.285 * s, 0.072 - 0.072 * s, 0],
163
+ [0.213 - 0.213 * s, 0.715 - 0.715 * s, 0.072 + 0.928 * s, 0],
164
+ );
165
+ }
166
+ case 'brightness':
167
+ return amount === 1 ? null : linear(amount, 0);
168
+ case 'contrast':
169
+ return amount === 1 ? null : linear(amount, 0.5 - 0.5 * amount);
170
+ case 'invert': {
171
+ const a = unit(amount);
172
+ return a === 0 ? null : linear(1 - 2 * a, a);
173
+ }
174
+ }
175
+ return null;
176
+ }
177
+
178
+ function hueRotate(degrees) {
179
+ if (degrees % 360 === 0) return null;
180
+ const t = (degrees * Math.PI) / 180;
181
+ const c = Math.cos(t);
182
+ const s = Math.sin(t);
183
+ return rows(
184
+ [
185
+ 0.213 + c * 0.787 - s * 0.213,
186
+ 0.715 - c * 0.715 - s * 0.715,
187
+ 0.072 - c * 0.072 + s * 0.928,
188
+ 0,
189
+ ],
190
+ [
191
+ 0.213 - c * 0.213 + s * 0.143,
192
+ 0.715 + c * 0.285 + s * 0.14,
193
+ 0.072 - c * 0.072 - s * 0.283,
194
+ 0,
195
+ ],
196
+ [
197
+ 0.213 - c * 0.213 - s * 0.787,
198
+ 0.715 - c * 0.715 + s * 0.715,
199
+ 0.072 + c * 0.928 + s * 0.072,
200
+ 0,
201
+ ],
202
+ );
203
+ }
204
+
205
+ /** A straight `[r, g, b, a]`, 0–1, through the filter: each function's
206
+ * result clamped before the next, as each one's is. */
207
+ export function filterColour([r, g, b, a], filter) {
208
+ for (const m of filter.matrices) {
209
+ const r1 = m[0] * r + m[1] * g + m[2] * b + m[3];
210
+ const g1 = m[4] * r + m[5] * g + m[6] * b + m[7];
211
+ const b1 = m[8] * r + m[9] * g + m[10] * b + m[11];
212
+ r = unit(r1);
213
+ g = unit(g1);
214
+ b = unit(b1);
215
+ }
216
+ return [r, g, b, a * filter.alpha];
217
+ }
218
+
219
+ /** A gradient's stops, flat as `[offset, r, g, b, a, …]`, through the
220
+ * filter: interpolating straight colours commutes with a matrix over them,
221
+ * but for what a clamp cuts off between two stops. */
222
+ export function filterStops(flat, filter) {
223
+ const out = flat.slice();
224
+ for (let i = 0; i + 4 < out.length; i += 5) {
225
+ const [r, g, b, a] = filterColour(
226
+ [out[i + 1], out[i + 2], out[i + 3], out[i + 4]],
227
+ filter,
228
+ );
229
+ out[i + 1] = r;
230
+ out[i + 2] = g;
231
+ out[i + 3] = b;
232
+ out[i + 4] = a;
233
+ }
234
+ return out;
235
+ }
236
+
237
+ /**
238
+ * Straight RGBA pixels, 0–255, through the filter, `src` into `dst` — the
239
+ * two may be one. A pixel with no alpha keeps none: its colour is no
240
+ * colour.
241
+ */
242
+ export function filterPixels(src, dst, filter) {
243
+ const { matrices } = filter;
244
+ const fade = filter.alpha;
245
+ const one = matrices.length === 1 ? matrices[0] : null;
246
+ for (let i = 0; i < src.length; i += 4) {
247
+ const a = src[i + 3];
248
+ if (a === 0) {
249
+ dst[i] = 0;
250
+ dst[i + 1] = 0;
251
+ dst[i + 2] = 0;
252
+ dst[i + 3] = 0;
253
+ continue;
254
+ }
255
+ let r = src[i];
256
+ let g = src[i + 1];
257
+ let b = src[i + 2];
258
+ if (one) {
259
+ const m = one;
260
+ const r1 = m[0] * r + m[1] * g + m[2] * b + m[3] * 255;
261
+ const g1 = m[4] * r + m[5] * g + m[6] * b + m[7] * 255;
262
+ const b1 = m[8] * r + m[9] * g + m[10] * b + m[11] * 255;
263
+ r = r1;
264
+ g = g1;
265
+ b = b1;
266
+ } else {
267
+ for (const m of matrices) {
268
+ const r1 = m[0] * r + m[1] * g + m[2] * b + m[3] * 255;
269
+ const g1 = m[4] * r + m[5] * g + m[6] * b + m[7] * 255;
270
+ const b1 = m[8] * r + m[9] * g + m[10] * b + m[11] * 255;
271
+ r = r1 < 0 ? 0 : r1 > 255 ? 255 : r1;
272
+ g = g1 < 0 ? 0 : g1 > 255 ? 255 : g1;
273
+ b = b1 < 0 ? 0 : b1 > 255 ? 255 : b1;
274
+ }
275
+ }
276
+ dst[i] = clampByte(r);
277
+ dst[i + 1] = clampByte(g);
278
+ dst[i + 2] = clampByte(b);
279
+ dst[i + 3] = fade === 1 ? a : clampByte(a * fade);
280
+ }
281
+ }
282
+
283
+ function clampByte(v) {
284
+ return v <= 0 ? 0 : v >= 255 ? 255 : Math.round(v);
285
+ }
package/src/cocoa/app.js CHANGED
@@ -286,6 +286,13 @@ export class CocoaApp {
286
286
  // X11's, where every toolkit hovers in a window without the keyboard.
287
287
  this.hoverNeedsActiveWindow = true;
288
288
 
289
+ // Shortcuts are pressed with ⌘ here (src/keysyms.js `primaryModifier`):
290
+ // the text controls' Undo/Cut/Copy/Paste/Select All answer ⌘ rather
291
+ // than ⌃, and the edit menu prints them the way a Mac menu does. The
292
+ // menu bar already read a chord's `Control` as ⌘ (cocoa/globalmenu.js);
293
+ // this is the same rule for everything else that reads one.
294
+ this.primaryModifier = 'Super';
295
+
289
296
  // AppKit-rendered control bezels. Its *presence* is the capability:
290
297
  // `useSupports('nativeControls')` and the widget set's `controls:
291
298
  // 'auto'` policy both test for this property, so a backend without it
@@ -35,6 +35,9 @@ const FLAG_COMMAND = 1 << 20;
35
35
  * user presses. Multi-chord sequences and non-character keys have no
36
36
  * NSMenu spelling and are dropped (the shortcut still works — the app's
37
37
  * own key handling is untouched; only the menu's hint goes unshown).
38
+ *
39
+ * `named` is the modifiers the chord itself asked for, before the ⌘ a bare
40
+ * key is given so that NSMenu has something to show.
38
41
  */
39
42
  function keyEquivalent(shortcut) {
40
43
  const chord = Array.isArray(shortcut) ? shortcut[0] : null;
@@ -48,7 +51,26 @@ function keyEquivalent(shortcut) {
48
51
  else key = part;
49
52
  }
50
53
  if (typeof key !== 'string' || [...key].length !== 1) return null;
51
- return { key: key.toLowerCase(), modifiers: modifiers || FLAG_COMMAND };
54
+ return {
55
+ key: key.toLowerCase(),
56
+ modifiers: modifiers || FLAG_COMMAND,
57
+ named: modifiers,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Does the menu bar answer this `shortcut` itself, on the chord the app
63
+ * wrote? The ones it holds as a key equivalent that carries ⌘ because the
64
+ * chord said `Control` or `Super`: AppKit offers a ⌘ key to the main menu's
65
+ * `performKeyEquivalent:` inside `[NSApp sendEvent:]`, so the item fires
66
+ * with no help from us. A bare key padded out to ⌘ is not one — the chord
67
+ * names the bare key, and the window still answers that.
68
+ *
69
+ * The bridge shows JS every key before AppKit sees it, so a `MenuBar` that
70
+ * matched these too would run the item twice (`useMenuAccelerators`).
71
+ */
72
+ export function answersShortcut(shortcut) {
73
+ return Boolean(keyEquivalent(shortcut)?.named & FLAG_COMMAND);
52
74
  }
53
75
 
54
76
  /**
@@ -117,6 +139,15 @@ export class CocoaGlobalMenuExport {
117
139
  if (this.app._activeGlobalMenu === this) this._install();
118
140
  }
119
141
 
142
+ /**
143
+ * The delegate's half of the key: the shortcuts the menu bar answers on
144
+ * its own, which the window must then leave alone (`answersShortcut`).
145
+ * The D-Bus export has no such method, because a panel delivers no keys.
146
+ */
147
+ answersShortcut(shortcut) {
148
+ return answersShortcut(shortcut);
149
+ }
150
+
120
151
  _install() {
121
152
  this.app._native.setMainMenu(this._spec());
122
153
  }
@@ -12,9 +12,25 @@
12
12
  // And the players its `<video src>`s ask for, which a process with no
13
13
  // AppKit would never hear from: each is a player here, its events and its
14
14
  // frames sent back (`HostedPlayer`, src/cocoa/paneplayer.js).
15
+ import { now } from '../nodes/animation.js';
15
16
  import { HostedPlayer } from './paneplayer.js';
16
17
  import { withoutActions } from './quiet.js';
17
18
 
19
+ /** The live-resize handshake's budget when the app names none
20
+ * (`RESIZE_WAIT_MS`, src/cocoa/app.js). */
21
+ const RESIZE_WAIT_MS = 50;
22
+
23
+ /** How many sizes asked of the pane are remembered until a frame of one
24
+ * answers them (`_asked`): a drag asks one a frame. */
25
+ const ASKED_KEPT = 16;
26
+
27
+ /** How long after the last size asked a new one starts a gesture of its
28
+ * own, which finds the pane keeping up until it shows otherwise: a pane
29
+ * whose layout at a width it was not dragged to took long — a long page
30
+ * laid out whole, as the first step of a drag is — keeps up with the
31
+ * drag itself. */
32
+ const GESTURE_GAP_MS = 500;
33
+
18
34
  /** One shared surface on glass: a sublayer of a window's root layer, its
19
35
  * contents the IOSurface the pane last presented into it. */
20
36
  class PaneLayer {
@@ -29,6 +45,82 @@ class PaneLayer {
29
45
  });
30
46
  this.destroyed = false;
31
47
  this._rect = null;
48
+ // How the frame the layer holds sits in it while one of the size the
49
+ // layer was just given is on its way (`_gravityNow`): the gravity set,
50
+ // Core Animation's own until one is, the sizes asked of the pane with
51
+ // when each was asked, and whether the pane has fallen behind them.
52
+ this._gravity = 'resize';
53
+ this._contentsScale = null;
54
+ this._asked = [];
55
+ this._askedAt = null;
56
+ this._behind = false;
57
+ }
58
+
59
+ /**
60
+ * The gravity a pane's last frame sits in its layer by, until a frame of
61
+ * the layer's new size lands. The host gives the layer its new size the
62
+ * moment the window lays out, and the pane's frame of that size comes a
63
+ * pane frame later: Core Animation's default, `resize`, stretches the
64
+ * frame it has to the new bounds meanwhile, and a page's left column was
65
+ * drawn scaled a little at every step of a window dragged wider, and then
66
+ * at its size again. A pane that keeps up has its frame anchored at the
67
+ * top left instead, at its size, what it does not cover yet showing the
68
+ * window under it for a pane frame; one that falls behind the budget is
69
+ * stretched, which is the better picture for the long wait — a pane
70
+ * repainting at three frames a second shows a squeezed page, not a page
71
+ * and a growing strip. The budget is the window's live-resize handshake's
72
+ * (`createRoot({ cocoa: { resizeWait } })`), how long a frame of a new
73
+ * size may take before the old one is shown in its place: 0 stretches
74
+ * always, as before. A bridge that predates `contentsGravity` ignores it,
75
+ * and stretches.
76
+ */
77
+ _gravityNow() {
78
+ const budget = this.app._resizeWait ?? RESIZE_WAIT_MS;
79
+ if (!(budget > 0)) return 'resize';
80
+ // a size asked longer ago than the budget, and no frame of it yet
81
+ const oldest = this._asked[0];
82
+ if (oldest && now() - oldest.at > budget) this._behind = true;
83
+ return this._behind ? 'resize' : 'topLeft';
84
+ }
85
+
86
+ /** The props that set the gravity, where it changed. */
87
+ _gravityProps() {
88
+ const gravity = this._gravityNow();
89
+ if (gravity === this._gravity) return null;
90
+ this._gravity = gravity;
91
+ // a frame anchored and larger than the layer would hang out of it
92
+ return { contentsGravity: gravity, masksToBounds: gravity !== 'resize' };
93
+ }
94
+
95
+ /**
96
+ * The props that say how many of the pane's pixels make a point of the
97
+ * layer, where that changed: the window's scale, which the pane draws
98
+ * at. A frame stretched to the bounds never asked, and one anchored at
99
+ * its size is shown at it — pixels over `contentsScale` points, so at
100
+ * the default of 1 a page at 2x came out twice its size.
101
+ */
102
+ _scaleProps() {
103
+ const scale = this.wnd.scale ?? 1;
104
+ if (scale === this._contentsScale) return null;
105
+ this._contentsScale = scale;
106
+ return { contentsScale: scale };
107
+ }
108
+
109
+ /** A frame of `width` by `height` device px landed: what it answers of
110
+ * the sizes asked, and how long it took. */
111
+ _answered(width, height) {
112
+ const asked = this._asked;
113
+ // the pane rounds the logical size it was sent back to device pixels
114
+ const slack = Math.max(2, this.wnd.scale ?? 1);
115
+ const at = asked.findIndex(
116
+ (a) =>
117
+ Math.abs(a.width - width) <= slack &&
118
+ Math.abs(a.height - height) <= slack,
119
+ );
120
+ if (at < 0) return;
121
+ this._behind =
122
+ now() - asked[at].at > (this.app._resizeWait ?? RESIZE_WAIT_MS);
123
+ asked.splice(0, at + 1);
32
124
  }
33
125
 
34
126
  /** Geometry in device px, the node's abs — points at the layer. */
@@ -46,6 +138,17 @@ class PaneLayer {
46
138
  return;
47
139
  }
48
140
  this._rect = { ...rect };
141
+ // a size the pane is asked for, answered by the first frame of it
142
+ if (prev && (prev.width !== rect.width || prev.height !== rect.height)) {
143
+ const at = now();
144
+ if (at - (this._askedAt ?? -Infinity) >= GESTURE_GAP_MS) {
145
+ this._asked = [];
146
+ this._behind = false;
147
+ }
148
+ this._askedAt = at;
149
+ if (this._asked.length === ASKED_KEPT) this._asked.shift();
150
+ this._asked.push({ width: rect.width, height: rect.height, at });
151
+ }
49
152
  // Actions off: the layer is ours, not a presenter's, so no frame's
50
153
  // transaction covers it, and a bare set tweens the pane into place at
51
154
  // mount and after every resize — the trap CocoaGLArea._setLayerProps
@@ -57,6 +160,8 @@ class PaneLayer {
57
160
  frame: [rect.x / s, rect.y / s, rect.width / s, rect.height / s],
58
161
  zPosition: 1e7,
59
162
  hidden: false,
163
+ ...this._scaleProps(),
164
+ ...this._gravityProps(),
60
165
  });
61
166
  } finally {
62
167
  native.txCommit();
@@ -78,7 +183,7 @@ class PaneLayer {
78
183
  * already holds a reference to. Nothing else here throws; any other
79
184
  * error is the bug it says it is.
80
185
  */
81
- present(iosurfaceId) {
186
+ present(iosurfaceId, size = null) {
82
187
  if (this.destroyed) return false;
83
188
  try {
84
189
  this._native.setLayerContentsIOSurface(this.layer, iosurfaceId);
@@ -86,6 +191,17 @@ class PaneLayer {
86
191
  if (!/IOSurfaceLookup/.test(err?.message ?? '')) throw err;
87
192
  return false;
88
193
  }
194
+ // how long the pane took to a frame of a size it was asked for, which
195
+ // is what the gravity follows (`_gravityNow`)
196
+ if (size) {
197
+ this._answered(size.width, size.height);
198
+ const gravity = this._gravityProps();
199
+ if (gravity) {
200
+ withoutActions(this._native, () =>
201
+ this._native.setLayerProps(this.layer, gravity),
202
+ );
203
+ }
204
+ }
89
205
  return true;
90
206
  }
91
207