react-x11 2.47.2 → 2.47.4

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.
@@ -1,7 +1,13 @@
1
1
  // A window anchored to a rect in another (#255, #280): where it goes, and
2
2
  // following the anchor as it moves.
3
3
 
4
- import { anchorOffscreen, anchorRect, anchorScreenRect } from '../../anchor.js';
4
+ import {
5
+ anchorOffscreen,
6
+ anchorRect,
7
+ anchorScreenRect,
8
+ deviceAnchorArea,
9
+ } from '../../anchor.js';
10
+ import { availableArea } from '../../screens.js';
5
11
 
6
12
  /** Anchoring, installed onto `WindowNode.prototype` by window.js. */
7
13
  export class WindowAnchoring {
@@ -109,7 +115,10 @@ export class WindowAnchoring {
109
115
  }
110
116
  }
111
117
  if (lost || !size) return;
112
- const rect = this._anchorPlacement(size);
118
+ let rect = this._anchorPlacement(size);
119
+ if (!rect) return;
120
+ if (rect.placement === 'center') rect = this._keepCentredTop(rect, size);
121
+ this._placedSize = { width: size.width, height: size.height };
113
122
  if (!rect) return;
114
123
  if (this._placedAt?.x === rect.x && this._placedAt?.y === rect.y) return;
115
124
  this._placedAt = { x: rect.x, y: rect.y };
@@ -120,6 +129,82 @@ export class WindowAnchoring {
120
129
  }
121
130
  }
122
131
 
132
+ /**
133
+ * Where a **centred** window goes when it is placed again: centred
134
+ * afresh when what it is centred over has moved, and otherwise — its own
135
+ * size changed, nothing else did — grown from where it already is.
136
+ *
137
+ * A centred popup is a dialog, and a dialog that grows is one the user is
138
+ * reading: an error line under a field, a "details" section opening. Re-
139
+ * centring it moves its top edge by half the growth, so the title, the
140
+ * field and the button that was just pressed all jump up under the
141
+ * pointer. So it keeps its top edge and its horizontal centre, grows down
142
+ * (and sideways about the middle), and is pulled back only as far as it
143
+ * takes to stay on its monitor — which is where the actions row of a
144
+ * dialog that grew past the bottom of the screen would otherwise go.
145
+ *
146
+ * A window the window manager frames is the manager's and the user's to
147
+ * move once it is up — they may have dragged it — so it grows from where
148
+ * the server says it is, and is moved only when it would leave the
149
+ * monitor. Never to where it opened: that would undo the drag.
150
+ *
151
+ * Returns the rect to place at, or null for "stay put".
152
+ */
153
+ _keepCentredTop(rect, size) {
154
+ const was = this._centredFor;
155
+ const at = this._placedAt;
156
+ if (was && at) {
157
+ const again = this._anchorPlacement(was.size);
158
+ if (again && again.x === was.x && again.y === was.y) {
159
+ return this._growInPlace(size);
160
+ }
161
+ }
162
+ this._centredFor = { size: { ...size }, x: rect.x, y: rect.y };
163
+ return rect;
164
+ }
165
+
166
+ /** `_keepCentredTop`'s other half: this window at `size`, its top edge and
167
+ * its middle where they are, clamped into the monitor. */
168
+ _growInPlace(size) {
169
+ const managed = this.attributes.overrideRedirect === false;
170
+ // An override-redirect window is wherever it was last put, and the
171
+ // record of that is ours; a managed one may have been moved since.
172
+ const from = managed
173
+ ? (this.window._screenOrigin ?? this._placedAt)
174
+ : this._placedAt;
175
+ const old = this._placedSize ?? size;
176
+ let x = from.x + Math.round((old.width - size.width) / 2);
177
+ let y = from.y;
178
+ const area = this._anchorArea();
179
+ if (area) {
180
+ x = Math.max(area.x, Math.min(x, area.x + area.width - size.width));
181
+ y = Math.max(area.y, Math.min(y, area.y + area.height - size.height));
182
+ }
183
+ // Nothing to send. For a managed window that matters beyond the round
184
+ // trip: a position handed back to an X window manager is read as the
185
+ // frame's, and would push the window down by its titlebar.
186
+ if (managed && x === from.x && y === from.y) return null;
187
+ return { x, y };
188
+ }
189
+
190
+ /** The monitor area this window is kept inside, in device pixels — the
191
+ * one its anchor picks (`anchorRect`, `anchorScreenRect`). */
192
+ _anchorArea() {
193
+ const anchor = this.props.anchor;
194
+ if (anchor.rect) {
195
+ const s = this.scale;
196
+ const r = anchor.rect;
197
+ return availableArea(this.app, {
198
+ x: r.x * s,
199
+ y: r.y * s,
200
+ width: (r.width ?? 0) * s,
201
+ height: (r.height ?? 0) * s,
202
+ });
203
+ }
204
+ const node = this._anchorTarget(anchor.to);
205
+ return node ? deviceAnchorArea(node) : null;
206
+ }
207
+
123
208
  /**
124
209
  * Whether there is nothing for this window to point at: the anchor's node
125
210
  * has scrolled out of view, or is a ref that has not attached yet — which
@@ -175,6 +175,11 @@ export class WindowFlush {
175
175
  // so a frame whose first pass is the floors' own collects once.
176
176
  this._floorsCollectOwed = true;
177
177
  this._refit();
178
+ // An `'auto'` window waiting for the size its content just asked for
179
+ // lays out when the echo brings it, not at the size it still has
180
+ // (`_holdForRefit`). Nothing here is cleared, so that frame owes
181
+ // everything this one did.
182
+ if (this._refitHolding()) return false;
178
183
  }
179
184
  const width = this.window.width ?? this._requestedSize?.width ?? 0;
180
185
  const height = this.window.height ?? this._requestedSize?.height ?? 0;
@@ -47,8 +47,10 @@ export class WindowListeners {
47
47
  // the first re-fit — and against all of them still in flight, not
48
48
  // only the latest, because a move echo or an earlier re-fit's can land
49
49
  // after the record moved on (#929, `_ownSize`).
50
- if (this._requestedSize && !this._ownSize(ev)) {
51
- this._userSized = true;
50
+ if (this._requestedSize) {
51
+ const own = this._ownSize(ev);
52
+ if (!own) this._userSized = true;
53
+ this._settleRefitHold(ev, own);
52
54
  }
53
55
  // Where the window sits on screen decides where popups anchored to it
54
56
  // belong — and finding that out is a server round trip
@@ -51,6 +51,16 @@ const NO_UNREAD_WIDTHS = process.env.REACT_X11_NO_UNREAD_WIDTHS === '1';
51
51
  // is a handful at most.
52
52
  const SIZES_IN_FLIGHT = 8;
53
53
 
54
+ // How long an `'auto'` window's frames wait for the size a re-fit asked for
55
+ // (`_holdForRefit`). A round trip is well under it locally and on a LAN; a
56
+ // slower link runs past it into the frame at the old size, which is the
57
+ // behaviour without the hold. The bound is there for a request answered
58
+ // with no new size at all — a window manager refusing it — where the
59
+ // content would otherwise wait for good.
60
+ const REFIT_HOLD_MS = 150;
61
+ // …and off, for A/B measurement and as first aid.
62
+ const NO_REFIT_HOLD = process.env.REACT_X11_NO_REFIT_HOLD === '1';
63
+
54
64
  /** Take a node's extents off, as `collectFloorStale` does a dirty child's:
55
65
  * both are measured again, and the minimum yoga holds may no longer be
56
66
  * the one the floors wrote. */
@@ -879,6 +889,20 @@ export class WindowSize {
879
889
  // tree holding the max-content one would hand `flush()` a stale
880
890
  // arrangement.
881
891
  yoga.calculateLayout(width, needH ? undefined : props.height, dir);
892
+ // **A height floor is a height for a width**, and the ones on hand
893
+ // were measured at the width the window was last laid out at — which
894
+ // is not this one whenever the content asked for a new width. A label
895
+ // that wrapped at the old width carries the two-line floor it earned
896
+ // there, and read back here it is a window two lines tall at a width
897
+ // that fits it on one: the window grew, and shrank again a frame
898
+ // later once the real pass had re-measured (#929). The same probe the
899
+ // real pass asks (`_applyContentFloors`): whether any leaf's height at
900
+ // this width differs from the one its floor was taken at, and only
901
+ // then the measurement and one more pass.
902
+ if (needH && this._probeHeightFloors().owed) {
903
+ measuringExactly(() => this._measureHeightFloors(width, false));
904
+ yoga.calculateLayout(width, undefined, dir);
905
+ }
882
906
  const naturalH = needH
883
907
  ? clampExtent(yoga.getComputedHeight(), undefined, availH)
884
908
  : undefined;
@@ -1001,18 +1025,26 @@ export class WindowSize {
1001
1025
  }
1002
1026
  this._askSize({ width: next.width, height: next.height });
1003
1027
  // Asked for, not assumed. `window.width` stays what the server last said
1004
- // until the ConfigureNotify lands, and this frame lays out against that
1005
- // — the echo brings `needsLayout` and an unbounded repaint with it (see
1006
- // the 'resize' listener), which is the same one-frame settle a
1007
- // controlled `width` prop change has always had. Writing the new size
1008
- // onto the window here would be worse than the wait: ntk allocates the
1009
- // backing pixmap from the resize event, so a frame painted at a size the
1010
- // pixmap has not reached yet is a frame clipped to the old one.
1028
+ // until the ConfigureNotify lands — on X11; Cocoa takes a size on the
1029
+ // call — and writing the new size onto the window here would be worse
1030
+ // than waiting: ntk allocates the backing pixmap from the resize event,
1031
+ // so a frame painted at a size the pixmap has not reached yet is a frame
1032
+ // clipped to the old one.
1011
1033
  if (typeof wnd.setState === 'function') {
1012
1034
  wnd.setState({ width: next.width, height: next.height });
1013
1035
  } else {
1014
1036
  wnd.resize?.(next.width, next.height);
1015
1037
  }
1038
+ // …and laying this frame out at the size the window still has would be
1039
+ // worse again: content that changed *because* it needs more room is
1040
+ // content that does not fit the old size — a label one word longer
1041
+ // wraps, and is cut off at the bottom of a window one line tall — and
1042
+ // that frame went up, for a round trip, before the echo laid it out
1043
+ // again (#929). So the frame waits for the echo (`_holdForRefit`); the
1044
+ // old pixels stay up meanwhile, which is the last thing that was right.
1045
+ if (wnd.width !== next.width || wnd.height !== next.height) {
1046
+ this._holdForRefit(next);
1047
+ }
1016
1048
  // A window that grew is a window whose *placement* moved with it, and
1017
1049
  // the anchored ones have to be told: a completion list that gains a row
1018
1050
  // near the bottom of the screen is one that now flips above the caret.
@@ -1042,6 +1074,65 @@ export class WindowSize {
1042
1074
  );
1043
1075
  }
1044
1076
 
1077
+ /**
1078
+ * Hold this window's frames until the backend reports the size `_refit`
1079
+ * just asked for, so the content that asked for it is laid out once, at
1080
+ * that size, and never at the old one. The echo releases the hold (the
1081
+ * 'resize' listener, `_settleRefitHold`), and so does somebody else's size
1082
+ * arriving instead — the user's or a window manager's. A request that is
1083
+ * never answered with a new size is the one case left: a window manager
1084
+ * may refuse it, and its answer then carries the old size, which a move
1085
+ * echo also does. So a timer ends the hold too, and the frame goes up at
1086
+ * whatever size the window has — what every frame did before the hold.
1087
+ */
1088
+ _holdForRefit(size) {
1089
+ if (NO_REFIT_HOLD) return;
1090
+ clearTimeout(this._refitHeld?.timer);
1091
+ const timer = setTimeout(() => {
1092
+ if (this._refitHeld?.timer !== timer) return;
1093
+ this._releaseRefitHold();
1094
+ }, REFIT_HOLD_MS);
1095
+ timer.unref?.();
1096
+ this._refitHeld = { width: size.width, height: size.height, timer };
1097
+ }
1098
+
1099
+ /** Whether this frame waits for the size `_refit` asked for: held, and
1100
+ * the window not that size yet. */
1101
+ _refitHolding() {
1102
+ const held = this._refitHeld;
1103
+ if (!held) return false;
1104
+ const wnd = this.window;
1105
+ if (wnd && (wnd.width !== held.width || wnd.height !== held.height)) {
1106
+ return true;
1107
+ }
1108
+ this._releaseRefitHold(false);
1109
+ return false;
1110
+ }
1111
+
1112
+ /**
1113
+ * What a resize event does to a hold: the size it was waiting for, or a
1114
+ * size that is not ours (`own` false), ends it. A move echo at the old
1115
+ * size, or the echo of an earlier re-fit still in flight, does not —
1116
+ * those are exactly the frames the hold is there to skip.
1117
+ */
1118
+ _settleRefitHold(ev, own) {
1119
+ const held = this._refitHeld;
1120
+ if (!held) return;
1121
+ if (!own || (ev.width === held.width && ev.height === held.height)) {
1122
+ this._releaseRefitHold();
1123
+ }
1124
+ }
1125
+
1126
+ /** End a hold, and run the frame it held back unless that is the caller's
1127
+ * own (`schedule` false). */
1128
+ _releaseRefitHold(schedule = true) {
1129
+ const held = this._refitHeld;
1130
+ if (!held) return;
1131
+ clearTimeout(held.timer);
1132
+ this._refitHeld = null;
1133
+ if (schedule && !this.destroyed && this.needsLayout) this._scheduleFrame();
1134
+ }
1135
+
1045
1136
  /**
1046
1137
  * Record a size this window has asked the backend for. `_requestedSize`
1047
1138
  * is the latest, which `_refit()` compares against to ask only once; the
@@ -275,6 +275,9 @@ export class WindowNode extends Scrollable(Node) {
275
275
  // has not echoed yet: both are ours when an echo carries them (_ownSize)
276
276
  this._confirmedSize = null;
277
277
  this._sizesInFlight = [];
278
+ // the size `_refit` is waiting for before it lays out again, and the
279
+ // timer that gives up on it (_holdForRefit)
280
+ this._refitHeld = null;
278
281
  // the last `WM_NORMAL_HINTS` struct written and the size it was written
279
282
  // at, so that a bound measured every frame is only *sent* on the frames
280
283
  // it moves (see _sendSizeHints for why the size is part of it)
@@ -340,6 +343,13 @@ export class WindowNode extends Scrollable(Node) {
340
343
  attributes.x = placed.x;
341
344
  attributes.y = placed.y;
342
345
  this._placedAt = { x: placed.x, y: placed.y };
346
+ this._placedSize = { width: natural.width, height: natural.height };
347
+ // what a centred window was centred at, so a later change of its own
348
+ // size grows it from here rather than centring it again
349
+ // (`_keepCentredTop`)
350
+ if (placed.placement === 'center') {
351
+ this._centredFor = { size: this._placedSize, x: placed.x, y: placed.y };
352
+ }
343
353
  }
344
354
  // A bound the content decides is a number by now, and the window manager
345
355
  // reads `WM_NORMAL_HINTS` when it frames the window — so it goes in with
@@ -920,6 +930,8 @@ export class WindowNode extends Scrollable(Node) {
920
930
  this._followAnchor();
921
931
  } else if (before.anchor) {
922
932
  this._placedAt = null;
933
+ this._placedSize = null;
934
+ this._centredFor = null;
923
935
  // A popup that gives up its anchor gives up being hidden by one: it
924
936
  // is an ordinary `x`/`y` popup from here, and the configure above
925
937
  // has already put it where the app asked.
@@ -495,8 +495,22 @@ export interface DialogProps extends WidgetProps {
495
495
  managed?: boolean;
496
496
  onClose?: () => void;
497
497
  actions?: ReactNode;
498
- width?: number;
499
- height?: number;
498
+ /**
499
+ * The dialog's width, 360 by default — a number, because a body of prose
500
+ * wraps to it, where a natural width would be as wide as its longest
501
+ * sentence. `'auto'` sizes it to its content, capped at the monitor.
502
+ */
503
+ width?: number | 'auto';
504
+ /**
505
+ * `'auto'` (the default) is the content's height: the dialog is measured,
506
+ * centred over its owner at that size and only then opened, and it keeps
507
+ * following its content while open, growing from its top edge rather than
508
+ * being centred again. Capped at the usable height of the monitor; past
509
+ * that the body scrolls and the `actions` row stays pinned at the bottom
510
+ * of the window. A number is that height exactly, with the same scrolling
511
+ * body when the content needs more.
512
+ */
513
+ height?: number | 'auto';
500
514
  style?: StyleProp;
501
515
  }
502
516
  export const Dialog: ComponentType<DialogProps>;
@@ -672,7 +686,14 @@ export const MenuBar: ComponentType<MenuBarProps>;
672
686
  export interface TabItem {
673
687
  id: string;
674
688
  label: string;
675
- content?: ReactNode;
689
+ /**
690
+ * The panel shown while this tab is selected. A function is the lazy form:
691
+ * it is called with no arguments, only while its tab is selected, and again
692
+ * on each render of `Tabs` while it stays selected — nothing is memoised, so
693
+ * a hidden tab's panel is never built. Leave it out to use `Tabs` as a pure
694
+ * navigator whose panel lives elsewhere.
695
+ */
696
+ content?: ReactNode | (() => ReactNode);
676
697
  disabled?: boolean;
677
698
  }
678
699
 
@@ -748,12 +769,17 @@ export const SplitPane: ComponentType<SplitPaneProps>;
748
769
  /**
749
770
  * The side a popup is asked for. `'start'`/`'end'` are the **logical** ones
750
771
  * and mirror with the anchor's direction, which is what a submenu wants;
751
- * `'left'`/`'right'` stay physical.
772
+ * `'left'`/`'right'` stay physical. `'center'` is no side: the popup goes
773
+ * over the anchor, middle on middle, and is only clamped into the monitor —
774
+ * what `Dialog` uses over its owner window. On a `<popup anchor>` a centred
775
+ * popup is centred when it opens and grows from its top edge when its own
776
+ * size changes afterwards.
752
777
  */
753
- export type AnchorPlacement = Placement | 'start' | 'end';
778
+ export type AnchorPlacement = Placement | 'start' | 'end' | 'center';
754
779
 
755
780
  export interface AnchorOptions {
756
- /** Preferred side, flipped at a screen edge (default `'bottom'`). */
781
+ /** Preferred side, flipped at a screen edge (default `'bottom'`);
782
+ * `'center'` goes over the anchor instead, and ignores `align`/`offset`. */
757
783
  placement?: AnchorPlacement;
758
784
  /** Which edges line up on the cross axis (default `'start'`). */
759
785
  align?: 'start' | 'center' | 'end';
@@ -786,7 +812,7 @@ export interface AnchorOptions {
786
812
 
787
813
  /** An anchored rect, and the side it actually ended up on. */
788
814
  export interface AnchorRect extends Rect {
789
- placement: Placement;
815
+ placement: Placement | 'center';
790
816
  }
791
817
 
792
818
  /**
@@ -820,7 +846,8 @@ export interface ScreenAnchorRect {
820
846
  height?: number;
821
847
  }
822
848
 
823
- /** Centre a popup of this size on the node's screen. */
849
+ /** Centre a popup of this size over the window the node is in, clamped
850
+ * into the monitor — `placement: 'center'` against the window's rect. */
824
851
  export function centerRect(
825
852
  node: DrawnNode,
826
853
  size: { width: number; height: number },
@@ -739,7 +739,11 @@ export interface TextInputProps extends DrawnProps<TextInputNode> {
739
739
  * both read naturally.
740
740
  */
741
741
  onChange?: (ev: ChangeEvent<TextInputNode>) => void;
742
- /** Enter — or Ctrl+Enter in a `<textarea>` (⌘↩ on the Cocoa backend). */
742
+ /**
743
+ * Enter or Shift+Enter — or Ctrl+Enter in a `<textarea>` (⌘↩ on the Cocoa
744
+ * backend). Any other chord on Enter is left to the shortcuts, and so is
745
+ * Enter itself in a field with no `onSubmit`.
746
+ */
743
747
  onSubmit?: (ev: SubmitEvent<TextInputNode>) => void;
744
748
  placeholder?: string;
745
749
  placeholderColor?: Color;
@@ -169,7 +169,9 @@ export function useWindowState(
169
169
  *
170
170
  * Costs no timer where the X server carries an `IDLETIME` counter (Xorg
171
171
  * does): a SYNC alarm fires on each crossing. Falls back to polling
172
- * MIT-SCREEN-SAVER, and stays `false` on a display with neither.
172
+ * MIT-SCREEN-SAVER, and stays `false` on an X display with neither. On win32
173
+ * and macOS it polls the session's own idle time on the same adaptive
174
+ * schedule.
173
175
  */
174
176
  export function useIdle(timeout: number): boolean;
175
177