react-x11 2.9.2 → 2.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.9.2",
3
+ "version": "2.10.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -217,6 +217,58 @@ function brokenAtEveryOpportunity(spans) {
217
217
  return out;
218
218
  }
219
219
 
220
+ /** What a paragraph ends in when it did not fit. A face without the mark
221
+ * gets a substituted one from CoreText, where ntk falls back to three
222
+ * dots of its own — a difference only a face missing U+2026 can show. */
223
+ const ELLIPSIS = '\u2026';
224
+
225
+ /**
226
+ * The broken spans as a paragraph that **elides**: the first `maxLines - 1`
227
+ * lines, then a line that is only the ellipsis — set in the font of the run
228
+ * the cut fell in, as ntk sets it.
229
+ *
230
+ * This is the min-content floor of a paragraph that ends in a `…`. It is not
231
+ * the same question as a wrapping one's: a paragraph that may wrap has to be
232
+ * at least its longest word, because there is nowhere else for that word to
233
+ * go, while one that elides has somewhere — an offer narrower than its text
234
+ * is answered by cutting the text, so the width it *needs* is the mark that
235
+ * says so. ntk answers exactly that (a `<text textWrap="nowrap"
236
+ * textOverflow="ellipsis">` measures 30.472 at an offer of zero, which is
237
+ * the width of `…` in the same face, to the third decimal), and the two
238
+ * engines are supposed to measure a tree the same way.
239
+ *
240
+ * The cut is made here rather than by the native for a reason worth
241
+ * keeping: eliding is a question about a width, and the min-content probe
242
+ * has none to give. Asked to elide with no bound, CoreText keeps the line
243
+ * count but puts everything that was over it back on the last line — a
244
+ * `<Select>` caption floored at 472.5 against a max-content of 506.9, which
245
+ * pinned every eliding label on this backend at its full width and made it
246
+ * overflow sideways rather than give way — react-x11 #512.
247
+ */
248
+ function cappedToEllipsis(broken, maxLines) {
249
+ // A cap below one keeps one, as `_maxLines` does: nothing renders no
250
+ // paragraph at all, and a floor of zero is not a measurement.
251
+ const cap = Math.max(1, Math.floor(maxLines));
252
+ const lines = [[]];
253
+ for (const span of broken) {
254
+ if (span.text === '\n') lines.push([]);
255
+ else lines.at(-1).push(span);
256
+ }
257
+ // Nothing over the cap is nothing to stand for: a paragraph that fits in
258
+ // its lines is measured as the lines, ellipsis or not.
259
+ if (lines.length <= cap) return broken;
260
+
261
+ const cut = lines[cap - 1]?.[0] ?? broken[0];
262
+ const out = [];
263
+ for (const line of lines.slice(0, cap - 1)) {
264
+ if (out.length) out.push({ ...out.at(-1), text: '\n' });
265
+ out.push(...line);
266
+ }
267
+ if (out.length) out.push({ ...out.at(-1), text: '\n' });
268
+ out.push({ ...cut, text: ELLIPSIS });
269
+ return out;
270
+ }
271
+
220
272
  const ALIGN_FLUSH = { left: 0, center: 0.5, right: 1 };
221
273
 
222
274
  function flushFor(align, direction) {
@@ -890,17 +942,28 @@ export class CocoaFontManager {
890
942
  // columns came out 823px and 34px wide. `brokenAtEveryOpportunity` is
891
943
  // the answer instead.
892
944
  const minContent = Number.isFinite(maxWidth) && maxWidth <= 0;
893
- const laid = minContent
894
- ? brokenAtEveryOpportunity(nativeSpans)
895
- : nativeSpans;
945
+ // Whether this paragraph is one that ends in a `…`. Both halves: ntk
946
+ // elides off the line *count*, so an ellipsis with nothing to cap can
947
+ // never fire (nodes.js `_maxLines`), and neither can this.
948
+ const elides = overflow === 'ellipsis' && Number.isFinite(maxLines);
949
+ // An eliding paragraph's floor is the mark, not its longest word, and
950
+ // it is cut here because the native cannot cut without a width to cut
951
+ // to — see `cappedToEllipsis`. The cap and the ellipsis are in the text
952
+ // that comes back, so the request carries neither.
953
+ const probe = minContent && elides && nativeSpans.length > 0;
954
+ const laid = probe
955
+ ? cappedToEllipsis(brokenAtEveryOpportunity(nativeSpans), maxLines)
956
+ : minContent
957
+ ? brokenAtEveryOpportunity(nativeSpans)
958
+ : nativeSpans;
896
959
  const raw = this._native.createLayout({
897
960
  spans: laid,
898
961
  maxWidth:
899
962
  Number.isFinite(maxWidth) && maxWidth > 0 ? maxWidth : undefined,
900
963
  align: flushFor(align, direction),
901
964
  lineHeight: typeof lineHeight === 'number' ? lineHeight : undefined,
902
- maxLines: Number.isFinite(maxLines) ? maxLines : undefined,
903
- ellipsis: overflow === 'ellipsis',
965
+ maxLines: probe || !Number.isFinite(maxLines) ? undefined : maxLines,
966
+ ellipsis: !probe && overflow === 'ellipsis',
904
967
  rtl: direction === 'rtl',
905
968
  });
906
969
  // the text the native actually laid out: a min-content measurement's
@@ -60,6 +60,25 @@ const ITEM_PAD_RIGHT = ITEM_PAD;
60
60
  // fit in.
61
61
  const itemHeight = (fontSize) => capBand(fontSize) + ITEM_PAD * 2;
62
62
 
63
+ // A caption is one line. `<text>` wraps by default — right for a paragraph,
64
+ // wrong for the value a control is showing: a trigger is a fixed height (the
65
+ // bezel's own, in native mode), so a caption too long for the box wrapped to
66
+ // a second line and drew it *outside* the control, above the bezel. The rows
67
+ // of the menu are the same shape for the same reason: `metrics.row` tall,
68
+ // whatever is in them.
69
+ //
70
+ // A `…` rather than a clip, because the width is not the label's to
71
+ // negotiate — a dropdown is as wide as the form made it — so the honest
72
+ // answer to a caption that does not fit is to say that it did not, which is
73
+ // what NSPopUpButtonCell does with a title too long for its cell. It is
74
+ // also what makes the label *give way*: an eliding `nowrap` floors at the
75
+ // mark it would end in, where a clipping one keeps its full width and
76
+ // pushes whatever contains it wider (nodes.js, `_wrapWidth`).
77
+ const ONE_LINE = Object.freeze({
78
+ textWrap: 'nowrap',
79
+ textOverflow: 'ellipsis',
80
+ });
81
+
63
82
  /**
64
83
  * The menu's geometry: the palette's, or — where the trigger is a native
65
84
  * popup bezel — NSMenu's, so the sheet that drops from it is the one the
@@ -230,6 +249,7 @@ function Option({
230
249
  {
231
250
  style: [
232
251
  capTrim,
252
+ ONE_LINE,
233
253
  {
234
254
  color: active ? theme.hoverText : theme.text,
235
255
  fontSize: metrics.fontSize,
@@ -543,6 +563,7 @@ export function Select({
543
563
  {
544
564
  style: [
545
565
  capTrim,
566
+ ONE_LINE,
546
567
  nativeControls && nativeTitleStyle('regular'),
547
568
  { color: current ? theme.text : theme.textMuted },
548
569
  ],