react-x11 2.35.0 → 2.37.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.35.0",
3
+ "version": "2.37.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/appearance.js CHANGED
@@ -135,12 +135,22 @@ const SAME = (a, b) =>
135
135
  * and a re-render per announcement would be churn with nothing behind it.
136
136
  */
137
137
  function publish(values, source) {
138
+ // **A pin is a pin.** A rung can answer long after it was asked — the
139
+ // ladder a test started before releasing its pin, a watcher's next line, a
140
+ // portal signal — and its answer lands in whatever test is running by
141
+ // then. Taken, that turns the next test's pixels into this machine's
142
+ // desktop, which is the one thing the pin exists to prevent.
143
+ if (owner === 'test' && source !== 'test') return;
138
144
  const next = Object.freeze({ ...NOTHING, ...values, source });
139
145
  if (SAME(next, snapshot)) return;
140
146
  snapshot = next;
141
- // What came *off* the disk does not go back onto it, and a pinned test
142
- // value must never reach a developer's real cache file.
143
- if (source !== 'cache' && source !== 'test') save(next);
147
+ // Only what a rung answered is remembered. What came *off* the disk does
148
+ // not go back onto it, a pinned test value must never reach a developer's
149
+ // real cache file — and nor must the defaults a released pin falls back
150
+ // to (`source: null`), which every `cleanup()` publishes: written, they
151
+ // replaced the developer's remembered desktop after every test, and their
152
+ // next app drew its first frame in the wrong colours.
153
+ if (source && source !== 'cache' && source !== 'test') save(next);
144
154
  for (const fn of [...watchers]) {
145
155
  try {
146
156
  fn(snapshot);
@@ -162,27 +172,42 @@ function publish(values, source) {
162
172
  * Pass `null` to release the pin and let the ladder run again.
163
173
  */
164
174
  export function setAppearanceForTests(values) {
165
- probe = null;
166
- // Never touch the developer's own remembered answer either, in either
167
- // direction: `publish` will not write a pinned value, and marking the cache
168
- // as already checked keeps `load()` from reading one back over it.
169
- cacheChecked = true;
170
175
  if (values === null) {
171
- cacheChecked = false;
172
176
  // Releasing undoes a *pin*, and only a pin. A real rung's answer is not
173
177
  // this function's to throw away: `cleanup()` releases after every test,
174
178
  // and a suite that resolved the appearance for real would otherwise find
175
- // it wiped by the harness that was meant to leave it alone.
176
- if (owner === 'test') {
177
- owner = null;
178
- publish({}, null);
179
- }
179
+ // it wiped by the harness that was meant to leave it alone — or, with
180
+ // the cache marked unread, replaced on the next read by what the disk
181
+ // says, which is a verified answer turned back into a remembered one.
182
+ if (owner !== 'test') return;
183
+ owner = null;
184
+ probe = null;
185
+ // Unpinned, the remembered answer is this machine's to read again —
186
+ // intact, because neither the pin nor this release is ever written
187
+ // (`publish`).
188
+ cacheChecked = false;
189
+ publish({}, null);
180
190
  return;
181
191
  }
192
+ probe = null;
193
+ // Never touch the developer's own remembered answer either, in either
194
+ // direction: `publish` will not write a pinned value, and marking the cache
195
+ // as already checked keeps `load()` from reading one back over it.
196
+ cacheChecked = true;
182
197
  owner = 'test';
183
198
  publish(values, 'test');
184
199
  }
185
200
 
201
+ /**
202
+ * What is pinned right now, or null where nothing is. `react-x11/test`'s
203
+ * `setAppearance` merges over it, so changing one value keeps the rest of the
204
+ * pin — and asks the store rather than keeping a copy, which a direct
205
+ * `setAppearanceForTests` call would leave stale.
206
+ */
207
+ export function pinnedAppearanceForTests() {
208
+ return owner === 'test' ? snapshot : null;
209
+ }
210
+
186
211
  // --------------------------------------------------------------------------
187
212
  // The last known answer
188
213
  // --------------------------------------------------------------------------
@@ -929,6 +954,11 @@ async function runWatcher() {
929
954
  answered = true;
930
955
  publish(values, 'macos');
931
956
  done(true);
957
+ // Refused, because a test pinned the store while this was starting
958
+ // (`publish`): nothing will hear this watcher again, and while it
959
+ // lives the rung starts no other — so the ladder that runs once the
960
+ // pin is released would find the Mac silent.
961
+ if (owner === 'test') proc.kill();
932
962
  }
933
963
  };
934
964
  proc.stdout.setEncoding?.('utf8');
@@ -1020,7 +1050,10 @@ async function runLadder(app) {
1020
1050
  answered = false;
1021
1051
  }
1022
1052
  if (answered) {
1023
- owner = name;
1053
+ // A pin set while this rung was being asked keeps the store: the
1054
+ // rung's answer was refused (`publish`), and owning it would hand the
1055
+ // rung's live updates a store they must not write to.
1056
+ if (owner !== 'test') owner = name;
1024
1057
  return snapshot;
1025
1058
  }
1026
1059
  }
@@ -73,7 +73,7 @@ export function useAppearanceWhen(enabled) {
73
73
  * | `selection` | the fill under a selected menu or list row — a darker cut of the accent on macOS — or **null** |
74
74
  * | `contrast` | `'normal'` or `'high'` |
75
75
  * | `reducedMotion` | `true` when the user asked for less animation |
76
- * | `source` | `'portal'`, `'xsettings'`, `'macos'`, `'cache'`, or null |
76
+ * | `source` | `'portal'`, `'xsettings'`, `'macos'`, `'windows'`, `'cache'`, `'test'` (pinned by `react-x11/test`), or null |
77
77
  *
78
78
  * **`'no-preference'` means use your own default**, not "use light". And
79
79
  * `accent` is null far more often than not, so fall back to your own brand
@@ -194,6 +194,19 @@ class LinearGradient {
194
194
 
195
195
  const clamp01 = (v) => Math.min(1, Math.max(0, Number(v) || 0));
196
196
 
197
+ // What `textAlign`, `textBaseline` and `direction` take. A value outside
198
+ // them is ignored and the last one stays, as canvas has it.
199
+ const TEXT_ALIGNS = new Set(['start', 'end', 'left', 'right', 'center']);
200
+ const TEXT_BASELINES = new Set([
201
+ 'top',
202
+ 'hanging',
203
+ 'middle',
204
+ 'alphabetic',
205
+ 'ideographic',
206
+ 'bottom',
207
+ ]);
208
+ const DIRECTIONS = new Set(['ltr', 'rtl', 'inherit']);
209
+
197
210
  /**
198
211
  * The Render ops text draws with, numbered as XRender numbers them so a
199
212
  * caller's `ctx.Render?.PictOp?.Over ?? 3` reads the same on both
@@ -428,6 +441,9 @@ export class BackendContext2D {
428
441
  dash: [],
429
442
  dashOffset: 0,
430
443
  font: '10px sans-serif',
444
+ textAlign: 'start',
445
+ textBaseline: 'alphabetic',
446
+ direction: 'inherit',
431
447
  shadowBlur: 0,
432
448
  shadowOffsetX: 0,
433
449
  shadowOffsetY: 0,
@@ -654,6 +670,37 @@ export class BackendContext2D {
654
670
  this._state.font = String(value);
655
671
  }
656
672
 
673
+ /** Where `fillText`'s x is on the text: its start, end, left, right or
674
+ * centre. */
675
+ get textAlign() {
676
+ return this._state.textAlign;
677
+ }
678
+
679
+ set textAlign(value) {
680
+ if (TEXT_ALIGNS.has(value)) this._state.textAlign = value;
681
+ }
682
+
683
+ /** Where `fillText`'s y is on the text: its alphabetic baseline, or the
684
+ * top, middle or bottom of its em box, or its hanging or ideographic
685
+ * baseline. */
686
+ get textBaseline() {
687
+ return this._state.textBaseline;
688
+ }
689
+
690
+ set textBaseline(value) {
691
+ if (TEXT_BASELINES.has(value)) this._state.textBaseline = value;
692
+ }
693
+
694
+ /** Which way `start` and `end` face. `inherit`, with no element to take
695
+ * it from, is the text's own: right to left where it starts so. */
696
+ get direction() {
697
+ return this._state.direction;
698
+ }
699
+
700
+ set direction(value) {
701
+ if (DIRECTIONS.has(value)) this._state.direction = value;
702
+ }
703
+
657
704
  get shadowBlur() {
658
705
  return this._state.shadowBlur;
659
706
  }
@@ -1932,9 +1979,53 @@ export class BackendContext2D {
1932
1979
  fillText(text, x, y) {
1933
1980
  const layout = this._fontLayout(text);
1934
1981
  if (!layout) return;
1935
- // canvas fillText's y is the baseline; layouts draw from their top
1936
- const baseline = layout.lines[0]?.baseline ?? 0;
1937
- this._drawLayout(layout, x, y - baseline);
1982
+ // canvas fillText's y is on the baseline `textBaseline` names, and x on
1983
+ // the side `textAlign` does; a layout draws from its top left
1984
+ const line = layout.lines[0];
1985
+ const baseline = line?.baseline ?? 0;
1986
+ this._drawLayout(
1987
+ layout,
1988
+ x + this._alignOffset(layout, line),
1989
+ y - baseline + this._baselineOffset(line),
1990
+ );
1991
+ }
1992
+
1993
+ /** How far left of x the text starts: ntk's `_alignOffset`, so a label
1994
+ * centred on X11 is centred here too. */
1995
+ _alignOffset(layout, line) {
1996
+ let align = this._state.textAlign;
1997
+ if (align === 'start' || align === 'end') {
1998
+ const direction = this._state.direction;
1999
+ const rtl =
2000
+ direction === 'rtl' ||
2001
+ (direction === 'inherit' &&
2002
+ !!line?.runs?.find((r) => r.start === 0)?.rtl);
2003
+ align = (align === 'start') !== rtl ? 'left' : 'right';
2004
+ }
2005
+ const width = layout.width ?? 0;
2006
+ if (align === 'center') return -width / 2;
2007
+ if (align === 'right') return -width;
2008
+ return 0;
2009
+ }
2010
+
2011
+ /** How far below y the alphabetic baseline is: ntk's `_baselineOffset`,
2012
+ * from the face's ascent and descent. */
2013
+ _baselineOffset(line) {
2014
+ const ascent = line?.ascent ?? 0;
2015
+ const descent = line?.descent ?? 0;
2016
+ switch (this._state.textBaseline) {
2017
+ case 'top':
2018
+ return ascent;
2019
+ case 'hanging':
2020
+ return ascent * 0.8;
2021
+ case 'middle':
2022
+ return (ascent - descent) / 2;
2023
+ case 'bottom':
2024
+ case 'ideographic':
2025
+ return -descent;
2026
+ default:
2027
+ return 0;
2028
+ }
1938
2029
  }
1939
2030
 
1940
2031
  _fontLayout(text, color) {
@@ -29,7 +29,12 @@ import { cssColorStraight } from 'ntk/color';
29
29
  import { Node } from '../nodes/node.js';
30
30
  import { addDamageRect, damageToPaint } from '../nodes/damage.js';
31
31
  import { intersectRects } from '../nodes/rects.js';
32
- import { EASING_CONTROL_POINTS, TRANSITION_CONTROL_POINTS } from '../styles.js';
32
+ import { now as animationNow } from '../nodes/animation.js';
33
+ import {
34
+ EASING_CONTROL_POINTS,
35
+ TRANSITION_CONTROL_POINTS,
36
+ resolveBorderWidths,
37
+ } from '../styles.js';
33
38
  import { BackendContext2D } from '../backend/context2d.js';
34
39
 
35
40
  export const RASTER_PAD = 2; // antialiasing/italic overhang outside the ink bounds
@@ -361,6 +366,33 @@ const ANIMATED_KEY_PATHS = Object.freeze({
361
366
  opacity: { keyPath: 'opacity' },
362
367
  });
363
368
 
369
+ /**
370
+ * An inset of a node out of the flow moves that node and nothing else — no
371
+ * sibling, not its parent — so a loop on one is its layer's position: an
372
+ * offset added to where the layout put the node, which is at the inset's
373
+ * declared value while the render server runs the loop (the style is the
374
+ * model). Only for a node no pointer lands on (`pointerEvents: 'none'`),
375
+ * since input is hit against the layout's rect, which holds still: an
376
+ * indeterminate bar's block, a highlight that sweeps along a track. Which
377
+ * axis, and which way a larger value moves the layer; `start` and `end` are
378
+ * mirrored under RTL.
379
+ */
380
+ const INSET_KEY_PATHS = Object.freeze({
381
+ left: { keyPath: 'position.x', axis: 'x', sign: 1 },
382
+ right: { keyPath: 'position.x', axis: 'x', sign: -1 },
383
+ start: { keyPath: 'position.x', axis: 'x', sign: 1, logical: true },
384
+ end: { keyPath: 'position.x', axis: 'x', sign: -1, logical: true },
385
+ top: { keyPath: 'position.y', axis: 'y', sign: 1 },
386
+ bottom: { keyPath: 'position.y', axis: 'y', sign: -1 },
387
+ });
388
+
389
+ /** Does a loop on `prop` move its node, where a presenter runs it
390
+ * (`INSET_KEY_PATHS`)? */
391
+ export const movesNode = (prop) => Object.hasOwn(INSET_KEY_PATHS, prop);
392
+
393
+ // a length or a share of the room: `12`, `'-40%'`
394
+ const PERCENT = /^\s*(-?(?:\d+\.?\d*|\.\d+))%\s*$/;
395
+
364
396
  // the ids the bridge reports an animation's end under: unique per process,
365
397
  // looked up per app (`_animationEnds`)
366
398
  let animationSeq = 0;
@@ -544,12 +576,16 @@ export class LayerAnimations {
544
576
  * is in its bitmap, not on its layer.
545
577
  */
546
578
  take(node, prop, entry) {
547
- const map = ANIMATED_KEY_PATHS[prop];
579
+ const map = ANIMATED_KEY_PATHS[prop] ?? INSET_KEY_PATHS[prop];
548
580
  if (!map || !stylePaintsPlain(node, node._targetStyle ?? node.style)) {
549
581
  return false;
550
582
  }
551
- if (this._value(map, entry.from) == null) return false;
552
- if (this._value(map, entry.to) == null) return false;
583
+ if (map.axis) {
584
+ if (!this._insetOffsets(node, prop, entry)) return false;
585
+ } else {
586
+ if (this._value(map, entry.from) == null) return false;
587
+ if (this._value(map, entry.to) == null) return false;
588
+ }
553
589
  let pending = this.pending.get(node);
554
590
  if (!pending) this.pending.set(node, (pending = new Map()));
555
591
  pending.set(prop, entry);
@@ -559,7 +595,12 @@ export class LayerAnimations {
559
595
  /** Stop what runs for `prop` on `node`: a loop the window lost sight of,
560
596
  * a declaration that changed, a transition the clock takes back. */
561
597
  cancel(node, prop) {
562
- this.pending.get(node)?.delete(prop);
598
+ const pending = this.pending.get(node);
599
+ // …and nothing left waiting is no entry at all: an empty map still
600
+ // answers `has`, and a node with nothing to run went onto a layer it
601
+ // never gave back — a loop that went out of sight between its offer
602
+ // and its frame did
603
+ if (pending?.delete(prop) && pending.size === 0) this.pending.delete(node);
563
604
  this._removeLive(node, prop);
564
605
  }
565
606
 
@@ -585,6 +626,78 @@ export class LayerAnimations {
585
626
  : null;
586
627
  }
587
628
 
629
+ /**
630
+ * A loop on an inset of `node` as its layer's offsets from where the
631
+ * layout puts it, `[from, to]` in points — or null where it cannot be
632
+ * one: not a loop, a node in the flow or one a pointer can land on, an
633
+ * inset with no declared value to rest at, or a value that is neither a
634
+ * length nor a percentage. A percentage is a share of the parent's
635
+ * padding box, which is what the layout places a node out of the flow
636
+ * in, so the offsets change when the parent's size does (`follow`).
637
+ */
638
+ _insetOffsets(node, prop, entry) {
639
+ const map = INSET_KEY_PATHS[prop];
640
+ const style = node._targetStyle ?? node.style ?? {};
641
+ if (!entry.loop || style.position !== 'absolute') return null;
642
+ if (style.pointerEvents !== 'none') return null;
643
+ const parent = node.parent;
644
+ if (!parent?.abs) return null;
645
+ const edges = resolveBorderWidths(parent.style ?? {}, parent.direction);
646
+ const room =
647
+ map.axis === 'x'
648
+ ? parent.abs.width - edges.left - edges.right
649
+ : parent.abs.height - edges.top - edges.bottom;
650
+ const length = (value) => {
651
+ if (typeof value === 'number')
652
+ return Number.isFinite(value) ? value : null;
653
+ const share = typeof value === 'string' ? PERCENT.exec(value) : null;
654
+ return share ? (Number(share[1]) / 100) * room : null;
655
+ };
656
+ const rest = length(style[prop]);
657
+ const from = length(entry.from);
658
+ const to = length(entry.to);
659
+ if (rest === null || from === null || to === null) return null;
660
+ const sign = map.logical && node.direction === 'rtl' ? -map.sign : map.sign;
661
+ return [
662
+ (sign * (from - rest)) / this.scale,
663
+ (sign * (to - rest)) / this.scale,
664
+ ];
665
+ }
666
+
667
+ /**
668
+ * The offsets of a loop moving `node` as the layout has them now: one
669
+ * whose parent changed size runs again where it has got to in its cycle
670
+ * (`headStart`), and one that cannot run there any more goes back to the
671
+ * clock. Before `apply`, which attaches what this hands back.
672
+ */
673
+ follow(node, layer) {
674
+ const live = this.live.get(node);
675
+ if (!live) return;
676
+ for (const [id, run] of [...live]) {
677
+ if (!run.offsets) continue;
678
+ const offsets = this._insetOffsets(node, run.prop, run.entry);
679
+ if (
680
+ offsets &&
681
+ offsets[0] === run.offsets[0] &&
682
+ offsets[1] === run.offsets[1]
683
+ ) {
684
+ continue;
685
+ }
686
+ live.delete(id);
687
+ this._ends().delete(id);
688
+ this.native.removeAnimation(layer, run.key);
689
+ if (!offsets) {
690
+ node._offloadEnded(run.prop, run.entry);
691
+ continue;
692
+ }
693
+ run.entry.headStart = animationNow() - run.entry.start;
694
+ let pending = this.pending.get(node);
695
+ if (!pending) this.pending.set(node, (pending = new Map()));
696
+ pending.set(run.prop, run.entry);
697
+ }
698
+ if (live.size === 0) this.live.delete(node);
699
+ }
700
+
588
701
  /** The frame's half: attach what `take` accepted to `layer`, after the
589
702
  * model value went out, inside the same transaction. */
590
703
  apply(node, layer) {
@@ -592,23 +705,43 @@ export class LayerAnimations {
592
705
  if (!pending) return;
593
706
  this.pending.delete(node);
594
707
  for (const [prop, entry] of pending) {
595
- const map = ANIMATED_KEY_PATHS[prop];
708
+ const map = ANIMATED_KEY_PATHS[prop] ?? INSET_KEY_PATHS[prop];
596
709
  const id = `rx${++animationSeq}`;
597
710
  const key = `${prop}:${id}`;
598
711
  const opts = { duration: entry.duration / 1000, id };
712
+ let offsets = null;
713
+ if (map.axis) {
714
+ // an inset: the layer's offset from where the layout put it, added
715
+ // to its position — as the layout has the parent now, which may not
716
+ // be what it was at the swap
717
+ offsets = this._insetOffsets(node, prop, entry);
718
+ if (!offsets) {
719
+ node._offloadDeclined(prop, entry);
720
+ continue;
721
+ }
722
+ }
599
723
  if (entry.loop) {
600
724
  // a loop replaces whatever ran for the property: its declaration
601
725
  // changed, and a loop restarts from the top when it does
602
726
  this._removeLive(node, prop);
603
- opts.from = this._value(map, entry.from);
604
- opts.to = this._value(map, entry.to);
727
+ if (offsets) {
728
+ [opts.from, opts.to] = offsets;
729
+ opts.additive = true;
730
+ } else {
731
+ opts.from = this._value(map, entry.from);
732
+ opts.to = this._value(map, entry.to);
733
+ }
605
734
  opts.timing = EASING_CONTROL_POINTS[entry.easing];
606
735
  opts.repeat = Infinity;
607
736
  opts.autoreverse = entry.alternate;
608
737
  // CA's two halves of one CSS delay: a begin time the layer waits
609
- // for showing `from`, or a time offset that starts it that far in
610
- if (entry.delay > 0) opts.delay = entry.delay / 1000;
611
- else if (entry.delay < 0) opts.timeOffset = -entry.delay / 1000;
738
+ // for showing `from`, or a time offset that starts it that far in.
739
+ // A loop the clock has been running comes over where it has got to
740
+ // (`headStart`, nodes/animation.js `_offerLoopsAgain`), which is
741
+ // the same delay that much shorter.
742
+ const wait = (entry.delay ?? 0) - (entry.headStart ?? 0);
743
+ if (wait > 0) opts.delay = wait / 1000;
744
+ else if (wait < 0) opts.timeOffset = -wait / 1000;
612
745
  } else if (map.colour) {
613
746
  // From where the pixels are — which is what "an interrupted
614
747
  // transition reverses from where it got to" means here. A colour
@@ -636,7 +769,7 @@ export class LayerAnimations {
636
769
  // looked up after the removals above, which may have pruned the map
637
770
  let live = this.live.get(node);
638
771
  if (!live) this.live.set(node, (live = new Map()));
639
- live.set(id, { prop, key, entry });
772
+ live.set(id, { prop, key, entry, offsets });
640
773
  this._ends().set(id, (ev) => this._animationEnded(node, id, ev));
641
774
  }
642
775
  }
@@ -983,7 +1116,9 @@ export class CocoaLayerPresenter {
983
1116
  visual.set(
984
1117
  propBoxProps(node, this.window.app, this.scale, parentOrigin, order),
985
1118
  );
986
- // after the model value went out, inside the same transaction
1119
+ // after the model value went out, inside the same transaction: what
1120
+ // moves the node, as the layout has its parent now, then what waits
1121
+ this.animations.follow(node, visual.layer);
987
1122
  this.animations.apply(node, visual.layer);
988
1123
  }
989
1124