react-x11 2.39.0 → 2.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.39.0",
3
+ "version": "2.41.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -109,9 +109,9 @@
109
109
  "yoga-layout": "^3.2.1"
110
110
  },
111
111
  "optionalDependencies": {
112
- "@windowkit/appkit": "^0.22.0",
112
+ "@windowkit/appkit": "^0.25.0",
113
113
  "@windowkit/wayland": "^3.1.1",
114
- "@windowkit/win32": "^0.0.6",
114
+ "@windowkit/win32": "^0.0.7",
115
115
  "dbus-native": "^0.16.0",
116
116
  "x11-dri": "^0.9.0"
117
117
  },
@@ -9,6 +9,8 @@
9
9
  import { useEffect, useMemo, useRef } from 'react';
10
10
 
11
11
  import { matchesShortcut } from './accelerators.js';
12
+ import { useAppOrNull } from './appcontext.js';
13
+ import { primaryModifier } from './keysyms.js';
12
14
  import { useTopLevelWindow } from './windowid.js';
13
15
 
14
16
  /**
@@ -52,10 +54,20 @@ function warnUnanchored() {
52
54
  * *not* a dependency: a chord pressed three minutes from now must run the
53
55
  * handler from the current render, and re-registering on every render would
54
56
  * also reorder the bindings under it.
57
+ *
58
+ * `reserves(ev)`, read the same way, says the key belongs to something
59
+ * outside the window that answers it by itself — a delegated `MenuBar`'s
60
+ * key equivalents on the Cocoa backend — so no binding gets it
61
+ * (`EventManager._runAccelerators`).
55
62
  */
56
- export function useAcceleratorEntry(anchorRef, handle, enabled = true) {
57
- const live = useRef(handle);
58
- live.current = handle;
63
+ export function useAcceleratorEntry(
64
+ anchorRef,
65
+ handle,
66
+ enabled = true,
67
+ reserves = null,
68
+ ) {
69
+ const live = useRef({ handle, reserves });
70
+ live.current = { handle, reserves };
59
71
  useEffect(() => {
60
72
  if (!enabled) return undefined;
61
73
  const manager = anchorRef.current?.root?.events;
@@ -65,7 +77,8 @@ export function useAcceleratorEntry(anchorRef, handle, enabled = true) {
65
77
  }
66
78
  return manager.registerAccelerator({
67
79
  anchor: () => anchorRef.current ?? null,
68
- handle: (ev) => live.current?.(ev) ?? false,
80
+ handle: (ev) => live.current.handle?.(ev) ?? false,
81
+ reserves: (ev) => live.current.reserves?.(ev) ?? false,
69
82
  });
70
83
  }, [anchorRef, enabled]);
71
84
  }
@@ -78,7 +91,8 @@ export function useAcceleratorEntry(anchorRef, handle, enabled = true) {
78
91
  * a shortcut can be moved into or out of a menu without being rewritten, and
79
92
  * the same rules apply to it: exact on Control/Alt/Shift/Super, indifferent
80
93
  * to Caps Lock and Num Lock, matched against the Latin keysym so it survives
81
- * a layout switch, and behind whatever a focused element consumed with
94
+ * a layout switch, `Control` meaning ⌘ on the Cocoa backend
95
+ * (`primaryModifier`), and behind whatever a focused element consumed with
82
96
  * `preventDefault()`.
83
97
  *
84
98
  * The handler is called with the key event, and the key is consumed.
@@ -106,12 +120,14 @@ export function useAccelerator(shortcut, handler, options = {}) {
106
120
  },
107
121
  [scope, owner],
108
122
  );
109
- const live = useRef({ shortcut, handler });
110
- live.current = { shortcut, handler };
123
+ const primary = primaryModifier(useAppOrNull());
124
+ const live = useRef({ shortcut, handler, primary });
125
+ live.current = { shortcut, handler, primary };
111
126
  useAcceleratorEntry(
112
127
  anchorRef,
113
128
  (ev) => {
114
- if (!matchesShortcut(ev, live.current.shortcut)) return false;
129
+ const { shortcut: chords, primary: mod } = live.current;
130
+ if (!matchesShortcut(ev, chords, { primary: mod })) return false;
115
131
  live.current.handler?.(ev);
116
132
  return true;
117
133
  },
@@ -25,6 +25,16 @@
25
25
  // bit 1 and Num Lock is normally Mod2, so neither is in the comparison to
26
26
  // begin with. This is the line every hand-rolled binding gets wrong, which is
27
27
  // most of why it is here rather than in an application.
28
+ //
29
+ // ## Which modifier `Control` is
30
+ //
31
+ // The app's primary one (`primaryModifier`, src/keysyms.js): Ctrl, except on
32
+ // the Cocoa backend, where a cross-platform chord's `Control` is ⌘ — the rule
33
+ // a menu bar's key equivalents (cocoa/globalmenu.js) and a drawn row's label
34
+ // (`formatShortcut`) already follow, so the key a menu prints is the key that
35
+ // fires it. `Super` is ⌘ there too, and ⌃ has no token: a chord is matched
36
+ // with it up. The matcher is pure, so the primary is an option rather than
37
+ // something it looks up; the hooks hand it the app's.
28
38
 
29
39
  import {
30
40
  isEnabled,
@@ -68,17 +78,21 @@ export function chordKeysym(chord) {
68
78
  * The second path is **letters excluded** deliberately: it is exactly what
69
79
  * would make Ctrl+Shift+S fire a Ctrl+S binding, which is the thing the
70
80
  * modifier comparison is here to prevent.
81
+ *
82
+ * `primary` is the app's primary modifier: `'Super'` reads `Control` as ⌘,
83
+ * the way the Cocoa backend's menus print and install it.
71
84
  */
72
- export function matchesChord(ev, chord) {
85
+ export function matchesChord(ev, chord, { primary = 'Control' } = {}) {
73
86
  const { modifiers, key } = splitChord(chord);
74
87
  if (key == null) return false;
75
88
  const wanted = keysymFromName(key);
76
89
  if (wanted === undefined) return false;
77
90
 
78
- const ctrl = modifiers.has('Control');
91
+ const mac = primary === 'Super';
92
+ const ctrl = !mac && modifiers.has('Control');
79
93
  const alt = modifiers.has('Alt');
80
94
  const shift = modifiers.has('Shift');
81
- const meta = modifiers.has('Super');
95
+ const meta = modifiers.has('Super') || (mac && modifiers.has('Control'));
82
96
  if (
83
97
  Boolean(ev?.ctrlKey) !== ctrl ||
84
98
  Boolean(ev?.altKey) !== alt ||
@@ -98,11 +112,14 @@ export function matchesChord(ev, chord) {
98
112
  return (!shift || Boolean(ev?.shiftKey)) && ev?.codepoint === wanted;
99
113
  }
100
114
 
101
- /** Does this key event press any alternative of an `aas` shortcut? */
102
- export function matchesShortcut(ev, shortcut) {
115
+ /**
116
+ * Does this key event press any alternative of an `aas` shortcut? `options`
117
+ * is `matchesChord`'s: `{ primary }`.
118
+ */
119
+ export function matchesShortcut(ev, shortcut, options) {
103
120
  if (!Array.isArray(shortcut)) return false;
104
121
  return shortcut.some(
105
- (chord) => Array.isArray(chord) && matchesChord(ev, chord),
122
+ (chord) => Array.isArray(chord) && matchesChord(ev, chord, options),
106
123
  );
107
124
  }
108
125
 
@@ -117,11 +134,11 @@ export function matchesShortcut(ev, shortcut) {
117
134
  * the walk goes on, so a disabled "Save" does not also swallow the key from
118
135
  * whatever else claims it.
119
136
  */
120
- export function acceleratedItem(items, ev) {
137
+ export function acceleratedItem(items, ev, options) {
121
138
  for (const item of visibleItems(items)) {
122
139
  if (isSeparator(item) || !isEnabled(item)) continue;
123
- if (matchesShortcut(ev, item.shortcut)) return item;
124
- const inner = acceleratedItem(item.items, ev);
140
+ if (matchesShortcut(ev, item.shortcut, options)) return item;
141
+ const inner = acceleratedItem(item.items, ev, options);
125
142
  if (inner) return inner;
126
143
  }
127
144
  return null;
@@ -27,6 +27,13 @@ import { cssColorStraight } from 'ntk/color';
27
27
 
28
28
  import { normalizeRadii, planShadowTiles } from 'ntk/shadow-tiles';
29
29
 
30
+ import {
31
+ filterColour,
32
+ filterPixels,
33
+ filterStops,
34
+ filters,
35
+ parseCanvasFilter,
36
+ } from './filter.js';
30
37
  import { shadowTilesFor } from './shadowtiles.js';
31
38
 
32
39
  const BLACK = [0, 0, 0, 1];
@@ -107,6 +114,37 @@ function uploadImage(native, image) {
107
114
  return handle;
108
115
  }
109
116
 
117
+ /** `drawImage`'s arguments as the eight numbers of its longest form, the
118
+ * source rect and then the destination, whichever form they came in. */
119
+ function imageRects(size, args) {
120
+ let sx = 0;
121
+ let sy = 0;
122
+ let sw = size.width;
123
+ let sh = size.height;
124
+ let dx;
125
+ let dy;
126
+ let dw;
127
+ let dh;
128
+ if (args.length >= 8) {
129
+ [sx, sy, sw, sh, dx, dy, dw, dh] = args;
130
+ } else if (args.length >= 4) {
131
+ [dx, dy, dw, dh] = args;
132
+ } else {
133
+ [dx, dy] = args;
134
+ dw = sw;
135
+ dh = sh;
136
+ }
137
+ return [sx, sy, sw, sh, dx, dy, dw, dh];
138
+ }
139
+
140
+ /** Free a bitmap of the context's own making now, on a bridge that can;
141
+ * an older one frees it with the handle's finalizer. */
142
+ function release(native, handle) {
143
+ if (typeof native.releaseSurface === 'function') {
144
+ native.releaseSurface(handle);
145
+ }
146
+ }
147
+
110
148
  /** Free an Image's bitmap now, if it has one; drawing it again uploads it
111
149
  * again, as ntk's `destroy()` promises for its own copies. */
112
150
  export function releaseImageUpload(image) {
@@ -393,6 +431,13 @@ function meets(a, b) {
393
431
  );
394
432
  }
395
433
 
434
+ /** The qualities `imageSmoothingQuality` takes, canvas's. */
435
+ const SMOOTHING_QUALITIES = new Set(['low', 'medium', 'high']);
436
+
437
+ /** What the bridge is told to resample with: the quality, or the nearest
438
+ * pixel where smoothing is off. */
439
+ const smoothingOf = (st) => (st.smoothing ? st.smoothingQuality : 'none');
440
+
396
441
  /**
397
442
  * The off switch for shadow tiles (src/backend/shadowtiles.js): every
398
443
  * shadow drawn by the bridge, as before them. For measuring the tiles
@@ -453,6 +498,13 @@ export class BackendContext2D {
453
498
  // null: nothing clipped. A rect (surface pixels): that rect. NON_RECT:
454
499
  // a clip this class cannot name.
455
500
  clip: null,
501
+ // how an image drawn scaled or turned is resampled
502
+ smoothing: true,
503
+ smoothingQuality: 'medium',
504
+ // canvas's `filter` as it was set, and what it comes to
505
+ // (src/backend/filter.js): null for none
506
+ filter: 'none',
507
+ filterInk: null,
456
508
  };
457
509
  // What this bridge can do, asked once. Both verbs arrived together in
458
510
  // @windowkit/appkit 0.7.0, and anything older is a bridge that draws
@@ -461,6 +513,18 @@ export class BackendContext2D {
461
513
  // blit path is simply never taken.
462
514
  this._blendModes = typeof native.ctxSetBlendMode === 'function';
463
515
  this._blits = typeof native.blitSurface === 'function';
516
+ // and how an image is resampled, @windowkit/appkit 0.23.0's — asked
517
+ // with `in` as well, as `ctxPath` is below: a test's bridge that answers
518
+ // every name must not look like one that has it
519
+ this._smoothing =
520
+ 'ctxSetImageSmoothing' in native &&
521
+ typeof native.ctxSetImageSmoothing === 'function';
522
+ // and what a filter is applied to pixels with: reading a surface and
523
+ // writing one, which every Cocoa bridge has had since 0.4 — asked all
524
+ // the same, so a bridge without them keeps `filter` at none
525
+ this._filters = ['createSurface', 'ctxGetImageData', 'ctxPutImageData']
526
+ .map((name) => name in native && typeof native[name] === 'function')
527
+ .every(Boolean);
464
528
  this._onDirty = null;
465
529
  // the recorded path, and whether the native one still matches it (a
466
530
  // chunked stroke leaves only its last chunk behind)
@@ -527,6 +591,10 @@ export class BackendContext2D {
527
591
  if (this._blendModes && st.gco !== 'source-over') {
528
592
  n.ctxSetBlendMode(surface, st.gco);
529
593
  }
594
+ // and one made at 'medium', which a context starts at
595
+ if (this._smoothing && smoothingOf(st) !== 'medium') {
596
+ n.ctxSetImageSmoothing(surface, smoothingOf(st));
597
+ }
530
598
  // a fresh surface is unclipped, whatever the old one had in force
531
599
  st.clip = null;
532
600
  this._stack.length = 0;
@@ -642,6 +710,86 @@ export class BackendContext2D {
642
710
  this._state.gco = value;
643
711
  }
644
712
 
713
+ /**
714
+ * Canvas's `imageSmoothingEnabled` and `imageSmoothingQuality`: how an
715
+ * image or a surface drawn scaled or turned is resampled — the nearest
716
+ * pixel with smoothing off, and otherwise `'low'`, which is bilinear,
717
+ * `'medium'` or `'high'`. A context starts at `'medium'`, which is what
718
+ * the bridge makes its contexts at, and not at canvas's `'low'`: an image
719
+ * drawn small keeps the quality it was drawn at. Both are state `save`
720
+ * and `restore` keep.
721
+ *
722
+ * On macOS `'medium'` resamples the whole source for a draw through a
723
+ * matrix, whatever the clip, so a surface drawn a tile at a time through
724
+ * a perspective — `<Html>`'s box out of the plane — costs each tile all
725
+ * of it: 784 tiles of a 1400x1120 surface took 200 ms on an M1 Pro, and
726
+ * 27 at `'low'` (windowkit/appkit#109).
727
+ *
728
+ * A bridge with no `ctxSetImageSmoothing` keeps both where they start,
729
+ * so a caller learns whether an assignment took by reading it back, as
730
+ * with `globalCompositeOperation`.
731
+ */
732
+ get imageSmoothingEnabled() {
733
+ return this._state.smoothing;
734
+ }
735
+
736
+ set imageSmoothingEnabled(value) {
737
+ if (!this._smoothing) return;
738
+ // the surface first: a fresh one is told the state it is taking over
739
+ const surface = this._s();
740
+ this._state.smoothing = !!value;
741
+ this._native.ctxSetImageSmoothing(surface, smoothingOf(this._state));
742
+ }
743
+
744
+ get imageSmoothingQuality() {
745
+ return this._state.smoothingQuality;
746
+ }
747
+
748
+ set imageSmoothingQuality(value) {
749
+ if (!this._smoothing || !SMOOTHING_QUALITIES.has(value)) return;
750
+ const surface = this._s();
751
+ this._state.smoothingQuality = value;
752
+ this._native.ctxSetImageSmoothing(surface, smoothingOf(this._state));
753
+ }
754
+
755
+ /**
756
+ * Canvas's `filter`: a CSS `<filter-value-list>` applied to everything
757
+ * drawn after it, which `save` and `restore` keep. This context applies
758
+ * the colour functions — `grayscale()`, `sepia()`, `saturate()`,
759
+ * `hue-rotate()`, `invert()`, `brightness()`, `contrast()` — and
760
+ * `opacity()` (src/backend/filter.js): a fill, a stroke, a gradient, a
761
+ * glyph run and a symbol in the colours the filter makes of theirs; an
762
+ * image, a surface and a text layout that carries colours of its own
763
+ * through their pixels. A list with `blur()`, `drop-shadow()` or a
764
+ * `url()` in it, or what is no list, does not stick, nor does any on a
765
+ * bridge that cannot read a surface's pixels back: a caller knows
766
+ * whether an assignment took by reading it back, as with
767
+ * `globalCompositeOperation`. The value reads back as it was written.
768
+ */
769
+ get filter() {
770
+ return this._state.filter;
771
+ }
772
+
773
+ set filter(value) {
774
+ if (!this._filters) return;
775
+ const ink = parseCanvasFilter(value);
776
+ if (ink === undefined) return;
777
+ this._state.filter = ink === null ? 'none' : String(value).trim();
778
+ this._state.filterInk = filters(ink) ? ink : null;
779
+ }
780
+
781
+ /** A straight `[r, g, b, a]` as the filter in force draws it. */
782
+ _ink(rgba) {
783
+ const ink = this._state.filterInk;
784
+ return ink ? filterColour(rgba, ink) : rgba;
785
+ }
786
+
787
+ /** A gradient's flat stops as the filter in force draws them. */
788
+ _stops(flat) {
789
+ const ink = this._state.filterInk;
790
+ return ink ? filterStops(flat, ink) : flat;
791
+ }
792
+
645
793
  /**
646
794
  * Whether a stroke of a path with many subpaths may go out as several
647
795
  * `CGContextStrokePath` calls — on by default, because the alternative
@@ -1188,12 +1336,12 @@ export class BackendContext2D {
1188
1336
  }
1189
1337
 
1190
1338
  _applyFill() {
1191
- const [r, g, b, a] = parseColor(this._state.fillStyle);
1339
+ const [r, g, b, a] = this._ink(parseColor(this._state.fillStyle));
1192
1340
  this._native.ctxSetFillColor(this._s(), r, g, b, a);
1193
1341
  }
1194
1342
 
1195
1343
  _applyStroke() {
1196
- const [r, g, b, a] = parseColor(this._state.strokeStyle);
1344
+ const [r, g, b, a] = this._ink(parseColor(this._state.strokeStyle));
1197
1345
  this._native.ctxSetStrokeColor(this._s(), r, g, b, a);
1198
1346
  }
1199
1347
 
@@ -1305,7 +1453,8 @@ export class BackendContext2D {
1305
1453
  this._path();
1306
1454
  const style = this._state.fillStyle;
1307
1455
  if (style instanceof LinearGradient) {
1308
- const { coords, flat } = style._normalized();
1456
+ const { coords, flat: stops } = style._normalized();
1457
+ const flat = this._stops(stops);
1309
1458
  this._native.ctxFillLinearGradient(
1310
1459
  this._s(),
1311
1460
  coords[0],
@@ -1384,10 +1533,16 @@ export class BackendContext2D {
1384
1533
  if (!plan) return false;
1385
1534
  const clip = st.clip === NON_RECT ? null : st.clip;
1386
1535
  if (clip && !meets(plan.bounds, clip)) return true;
1387
- const tile = shadowTilesFor(this._native).get(
1388
- plan,
1389
- parseColor(st.shadowColor),
1390
- );
1536
+ // the shadow of what a filter's `opacity()` fades is faded with it;
1537
+ // its colour is the shadow's own, cast after the filter (canvas)
1538
+ const [sr, sg, sb, sa] = parseColor(st.shadowColor);
1539
+ const fade = st.filterInk?.alpha ?? 1;
1540
+ const tile = shadowTilesFor(this._native).get(plan, [
1541
+ sr,
1542
+ sg,
1543
+ sb,
1544
+ sa * fade,
1545
+ ]);
1391
1546
  if (tile == null) return false;
1392
1547
  const s = this._s();
1393
1548
  const n = this._native;
@@ -1441,16 +1596,19 @@ export class BackendContext2D {
1441
1596
  this._dirty();
1442
1597
  }
1443
1598
 
1444
- clip(pathOrRule) {
1445
- if (
1446
- pathOrRule != null &&
1447
- typeof pathOrRule === 'object' &&
1448
- !this._replayPath(pathOrRule)
1449
- ) {
1450
- return;
1451
- }
1599
+ /**
1600
+ * `clip()`, `clip(rule)`, `clip(path)`, `clip(path, rule)`, as canvas has
1601
+ * them and as `fill` takes them. The rule reaches the bridge as the flag
1602
+ * `ctxFill` takes: SvgView cuts to a `<clipPath>` with its `clip-rule`, and
1603
+ * a ring clipped `evenodd` by nonzero is a filled square. A bridge that
1604
+ * predates the flag ignores it and clips nonzero, as every bridge did.
1605
+ */
1606
+ clip(pathOrRule, maybeRule) {
1607
+ const hasPath = pathOrRule != null && typeof pathOrRule === 'object';
1608
+ const rule = hasPath ? maybeRule : pathOrRule;
1609
+ if (hasPath && !this._replayPath(pathOrRule)) return;
1452
1610
  this._state.clip = this._clipAfter();
1453
- this._native.ctxClip(this._path());
1611
+ this._native.ctxClip(this._path(), rule === 'evenodd');
1454
1612
  }
1455
1613
 
1456
1614
  /**
@@ -1466,6 +1624,10 @@ export class BackendContext2D {
1466
1624
  * sets — the damage rect, a scroll viewport, a square-cornered `overflow`
1467
1625
  * — are whole pixels under a translate, and those are the ones this
1468
1626
  * recognises.
1627
+ *
1628
+ * It answers for either fill rule. One rect is one closed subpath, wound
1629
+ * once round everything inside it, so `evenodd` and `nonzero` cut the
1630
+ * same pixels; a path of more than one shape is never a rect here.
1469
1631
  */
1470
1632
  _pathRect() {
1471
1633
  const cmds = this._cmds;
@@ -1527,7 +1689,8 @@ export class BackendContext2D {
1527
1689
  _fillRectNow(x, y, w, h) {
1528
1690
  const style = this._state.fillStyle;
1529
1691
  if (style instanceof LinearGradient) {
1530
- const { coords, flat } = style._normalized();
1692
+ const { coords, flat: stops } = style._normalized();
1693
+ const flat = this._stops(stops);
1531
1694
  this._native.ctxFillLinearGradient(
1532
1695
  this._s(),
1533
1696
  coords[0],
@@ -1592,22 +1755,11 @@ export class BackendContext2D {
1592
1755
  const src = this._sourceHandle(image);
1593
1756
  if (!src) return;
1594
1757
  const size = this._native.surfaceSize(src);
1595
- let sx = 0;
1596
- let sy = 0;
1597
- let sw = size.width;
1598
- let sh = size.height;
1599
- let dx;
1600
- let dy;
1601
- let dw;
1602
- let dh;
1603
- if (args.length >= 8) {
1604
- [sx, sy, sw, sh, dx, dy, dw, dh] = args;
1605
- } else if (args.length >= 4) {
1606
- [dx, dy, dw, dh] = args;
1607
- } else {
1608
- [dx, dy] = args;
1609
- dw = sw;
1610
- dh = sh;
1758
+ const [sx, sy, sw, sh, dx, dy, dw, dh] = imageRects(size, args);
1759
+ const ink = this._state.filterInk;
1760
+ if (ink) {
1761
+ this._drawFiltered(src, size, [sx, sy, sw, sh, dx, dy, dw, dh], ink);
1762
+ return;
1611
1763
  }
1612
1764
  if (this._blit(src, sx, sy, sw, sh, dx, dy, dw, dh)) {
1613
1765
  // a row memcpy
@@ -1642,6 +1794,44 @@ export class BackendContext2D {
1642
1794
  this._dirty();
1643
1795
  }
1644
1796
 
1797
+ /**
1798
+ * `drawImage` under a filter: the whole pixels of the source the source
1799
+ * rect covers, read from the bridge as they are, run through the filter,
1800
+ * put on a bitmap of their own, and that drawn as the source would have
1801
+ * been — the filter set aside for the draw, which a blit, a fade and the
1802
+ * resampling an `imageSmoothingQuality` sets all take as before.
1803
+ */
1804
+ _drawFiltered(src, size, [sx, sy, sw, sh, dx, dy, dw, dh], ink) {
1805
+ const x0 = Math.max(0, Math.floor(sx));
1806
+ const y0 = Math.max(0, Math.floor(sy));
1807
+ const w = Math.min(size.width, Math.ceil(sx + sw)) - x0;
1808
+ const h = Math.min(size.height, Math.ceil(sy + sh)) - y0;
1809
+ if (!(w > 0 && h > 0)) return;
1810
+ const n = this._native;
1811
+ const pixels = n.ctxGetImageData(src, x0, y0, w, h);
1812
+ filterPixels(pixels, pixels, ink);
1813
+ const filtered = n.createSurface(w, h, 1);
1814
+ const st = this._state;
1815
+ try {
1816
+ n.ctxPutImageData(filtered, pixels, w, h, 0, 0);
1817
+ st.filterInk = null;
1818
+ this.drawImage(
1819
+ { _surfaceHandle: filtered },
1820
+ sx - x0,
1821
+ sy - y0,
1822
+ sw,
1823
+ sh,
1824
+ dx,
1825
+ dy,
1826
+ dw,
1827
+ dh,
1828
+ );
1829
+ } finally {
1830
+ st.filterInk = ink;
1831
+ release(n, filtered);
1832
+ }
1833
+ }
1834
+
1645
1835
  /**
1646
1836
  * Whether a surface is drawn under this `globalAlpha` from its pixels
1647
1837
  * scaled by it (@windowkit/appkit's `ctxDrawSurfaceFaded`): where the
@@ -1906,7 +2096,7 @@ export class BackendContext2D {
1906
2096
  }
1907
2097
  }
1908
2098
  if (batches.size === 0) return;
1909
- const [r, g, b, a] = this._inkOf(src);
2099
+ const [r, g, b, a] = this._ink(this._inkOf(src));
1910
2100
  const surface = this._s();
1911
2101
  this._native.ctxSetFillColor(surface, r, g, b, a);
1912
2102
  const runs = [];
@@ -1945,10 +2135,15 @@ export class BackendContext2D {
1945
2135
  // --- text (minimal: enough for <canvas onDraw> users) --------------------
1946
2136
 
1947
2137
  _drawLayout(layout, x, y) {
2138
+ if (!layout._contextInk && this._state.filterInk) {
2139
+ this._drawLayoutFiltered(layout, x, y, this._state.filterInk);
2140
+ return;
2141
+ }
1948
2142
  if (layout._contextInk) {
1949
2143
  const style = this._state.fillStyle;
1950
2144
  if (style instanceof LinearGradient) {
1951
- const { coords, flat } = style._normalized();
2145
+ const { coords, flat: stops } = style._normalized();
2146
+ const flat = this._stops(stops);
1952
2147
  this._native.drawLayoutGradient(
1953
2148
  this._s(),
1954
2149
  layout._handle,
@@ -1969,6 +2164,68 @@ export class BackendContext2D {
1969
2164
  this._dirty();
1970
2165
  }
1971
2166
 
2167
+ /**
2168
+ * A layout that carries colours of its own, under a filter: the bridge
2169
+ * sets its glyphs in them, so it is drawn on a bitmap of its own the size
2170
+ * of where it lands on the surface — its box through the transform, and
2171
+ * half its height more each way for ink past the box — through the same
2172
+ * transform, and the bitmap is run through the filter and drawn where it
2173
+ * came from.
2174
+ */
2175
+ _drawLayoutFiltered(layout, x, y, ink) {
2176
+ const st = this._state;
2177
+ const [a, b, c, d, e, f] = st.ctm;
2178
+ const pad = (layout.height || 0) / 2;
2179
+ const left = x - pad;
2180
+ const top = y - pad;
2181
+ const right = x + (layout.width || 0) + pad;
2182
+ const bottom = y + (layout.height || 0) + pad;
2183
+ let X0 = Infinity;
2184
+ let Y0 = Infinity;
2185
+ let X1 = -Infinity;
2186
+ let Y1 = -Infinity;
2187
+ for (const [px, py] of [
2188
+ [left, top],
2189
+ [right, top],
2190
+ [left, bottom],
2191
+ [right, bottom],
2192
+ ]) {
2193
+ const qx = a * px + c * py + e;
2194
+ const qy = b * px + d * py + f;
2195
+ X0 = Math.min(X0, qx);
2196
+ Y0 = Math.min(Y0, qy);
2197
+ X1 = Math.max(X1, qx);
2198
+ Y1 = Math.max(Y1, qy);
2199
+ }
2200
+ const size = this._native.surfaceSize(this._s());
2201
+ X0 = Math.max(0, Math.floor(X0));
2202
+ Y0 = Math.max(0, Math.floor(Y0));
2203
+ X1 = Math.min(size.width, Math.ceil(X1));
2204
+ Y1 = Math.min(size.height, Math.ceil(Y1));
2205
+ const w = X1 - X0;
2206
+ const h = Y1 - Y0;
2207
+ if (!(w > 0 && h > 0)) return;
2208
+ const n = this._native;
2209
+ const own = n.createSurface(w, h, 1);
2210
+ try {
2211
+ n.ctxTransform(own, a, b, c, d, e - X0, f - Y0);
2212
+ n.drawLayout(own, layout._handle, x, y);
2213
+ const pixels = n.ctxGetImageData(own, 0, 0, w, h);
2214
+ filterPixels(pixels, pixels, ink);
2215
+ n.ctxPutImageData(own, pixels, w, h, 0, 0);
2216
+ this.save();
2217
+ try {
2218
+ this._state.filterInk = null;
2219
+ this.setTransform(1, 0, 0, 1, 0, 0);
2220
+ this.drawImage({ _surfaceHandle: own }, X0, Y0);
2221
+ } finally {
2222
+ this.restore();
2223
+ }
2224
+ } finally {
2225
+ release(n, own);
2226
+ }
2227
+ }
2228
+
1972
2229
  measureText(text) {
1973
2230
  const layout = this._fontLayout(text);
1974
2231
  return layout