react-x11 2.9.1 → 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 +1 -1
- package/src/cocoa/fonts.js +68 -5
- package/src/components/Button.js +68 -57
- package/src/components/Select.js +41 -3
- package/src/components/native.js +41 -0
package/package.json
CHANGED
package/src/cocoa/fonts.js
CHANGED
|
@@ -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
|
-
|
|
894
|
-
|
|
895
|
-
|
|
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) ?
|
|
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
|
package/src/components/Button.js
CHANGED
|
@@ -7,10 +7,12 @@ import { useAppOrNull } from '../appcontext.js';
|
|
|
7
7
|
import {
|
|
8
8
|
ABS_FILL,
|
|
9
9
|
Bezel,
|
|
10
|
+
NATIVE_BAND,
|
|
10
11
|
NATIVE_RING,
|
|
11
12
|
TITLE_BASELINE,
|
|
12
13
|
bezelNatural,
|
|
13
14
|
bezelShadow,
|
|
15
|
+
nativeFootprintStyle,
|
|
14
16
|
nativeTitleStyle,
|
|
15
17
|
pressWash,
|
|
16
18
|
useNativeControls,
|
|
@@ -94,64 +96,73 @@ export function Button({
|
|
|
94
96
|
'box',
|
|
95
97
|
{
|
|
96
98
|
theme,
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
style: [
|
|
101
|
-
controlStyle,
|
|
102
|
-
{
|
|
103
|
-
flexDirection: 'row',
|
|
104
|
-
alignItems: 'center',
|
|
105
|
-
justifyContent: 'center',
|
|
106
|
-
gap: small ? 6 : 8,
|
|
107
|
-
// AppKit's metrics, not the palette's: a native bezel is
|
|
108
|
-
// designed at its own height, and stretching it is what this
|
|
109
|
-
// mode exists to avoid. Width still follows the label.
|
|
110
|
-
height: nat.height,
|
|
111
|
-
paddingLeft: small ? 10 : 14,
|
|
112
|
-
paddingRight: small ? 10 : 14,
|
|
113
|
-
// The natural box is the bezel's footprint, shadow included;
|
|
114
|
-
// the body is what the title is placed against — and placed,
|
|
115
|
-
// not centred (`TITLE_BASELINE`). A label centred by its
|
|
116
|
-
// capitals sat 1pt low beside a native button.
|
|
117
|
-
paddingTop: shadow.top,
|
|
118
|
-
paddingBottom: shadow.bottom + TITLE_BASELINE[controlSize],
|
|
119
|
-
// The keyboard ring is the renderer's, on this box — shaped by
|
|
120
|
-
// the bezel's corners and hugging it, as AppKit's is
|
|
121
|
-
// (`NATIVE_RING`), rather than the palette's offset rectangle.
|
|
122
|
-
borderRadius: small ? 5 : 6,
|
|
123
|
-
':focus-visible': {
|
|
124
|
-
outlineWidth: NATIVE_RING.width,
|
|
125
|
-
outlineOffset: NATIVE_RING.offset,
|
|
126
|
-
},
|
|
127
|
-
color: disabled
|
|
128
|
-
? theme.textMuted
|
|
129
|
-
: primary
|
|
130
|
-
? theme.accentText
|
|
131
|
-
: theme.text,
|
|
132
|
-
},
|
|
133
|
-
style,
|
|
134
|
-
],
|
|
99
|
+
// The footprint the caller's style sizes; the button below keeps
|
|
100
|
+
// AppKit's height inside it, centred (`nativeFootprintStyle`).
|
|
101
|
+
style: [nativeFootprintStyle(nat), style],
|
|
135
102
|
},
|
|
136
|
-
h(
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
103
|
+
h(
|
|
104
|
+
'box',
|
|
105
|
+
{
|
|
106
|
+
theme,
|
|
107
|
+
role: 'button',
|
|
108
|
+
...props,
|
|
109
|
+
...boxProps,
|
|
110
|
+
style: [
|
|
111
|
+
controlStyle,
|
|
112
|
+
NATIVE_BAND,
|
|
113
|
+
{
|
|
114
|
+
flexDirection: 'row',
|
|
115
|
+
alignItems: 'center',
|
|
116
|
+
justifyContent: 'center',
|
|
117
|
+
gap: small ? 6 : 8,
|
|
118
|
+
// AppKit's metrics, not the palette's: a native bezel is
|
|
119
|
+
// designed at its own height, and stretching it is what this
|
|
120
|
+
// mode exists to avoid. Width still follows the label.
|
|
121
|
+
height: nat.height,
|
|
122
|
+
paddingLeft: small ? 10 : 14,
|
|
123
|
+
paddingRight: small ? 10 : 14,
|
|
124
|
+
// The natural box is the bezel's footprint, shadow included;
|
|
125
|
+
// the body is what the title is placed against — and placed,
|
|
126
|
+
// not centred (`TITLE_BASELINE`). A label centred by its
|
|
127
|
+
// capitals sat 1pt low beside a native button.
|
|
128
|
+
paddingTop: shadow.top,
|
|
129
|
+
paddingBottom: shadow.bottom + TITLE_BASELINE[controlSize],
|
|
130
|
+
// The keyboard ring is the renderer's, on this box — shaped by
|
|
131
|
+
// the bezel's corners and hugging it, as AppKit's is
|
|
132
|
+
// (`NATIVE_RING`), rather than the palette's offset rectangle.
|
|
133
|
+
borderRadius: small ? 5 : 6,
|
|
134
|
+
':focus-visible': {
|
|
135
|
+
outlineWidth: NATIVE_RING.width,
|
|
136
|
+
outlineOffset: NATIVE_RING.offset,
|
|
137
|
+
},
|
|
138
|
+
color: disabled
|
|
139
|
+
? theme.textMuted
|
|
140
|
+
: primary
|
|
141
|
+
? theme.accentText
|
|
142
|
+
: theme.text,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
h(Bezel, {
|
|
147
|
+
kind: 'push',
|
|
148
|
+
controlSize,
|
|
149
|
+
enabled: !disabled,
|
|
150
|
+
// the Return-key accent fill is AppKit's own "default button"
|
|
151
|
+
isDefault: primary && !disabled,
|
|
152
|
+
style: ABS_FILL,
|
|
153
|
+
}),
|
|
154
|
+
labelContent(children ?? label, nativeTitleStyle(controlSize)),
|
|
155
|
+
// The press answer. Last child on purpose: `:active` marks the
|
|
156
|
+
// pressed node and its ancestors, and the topmost child is what the
|
|
157
|
+
// press lands on. No hover tint — AppKit buttons have none.
|
|
158
|
+
h('box', {
|
|
159
|
+
style: [
|
|
160
|
+
ABS_FILL,
|
|
161
|
+
{ borderRadius: small ? 5 : 6 },
|
|
162
|
+
!disabled && { ':active': { backgroundColor: pressWash(theme) } },
|
|
163
|
+
],
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
155
166
|
);
|
|
156
167
|
}
|
|
157
168
|
const background = !solid
|
package/src/components/Select.js
CHANGED
|
@@ -9,11 +9,13 @@ import { Icon } from './Icon.js';
|
|
|
9
9
|
import {
|
|
10
10
|
ABS_FILL,
|
|
11
11
|
Bezel,
|
|
12
|
+
NATIVE_BAND,
|
|
12
13
|
NATIVE_MENU,
|
|
13
14
|
NATIVE_RING,
|
|
14
15
|
TITLE_BASELINE,
|
|
15
16
|
bezelNatural,
|
|
16
17
|
bezelShadow,
|
|
18
|
+
nativeFootprintStyle,
|
|
17
19
|
nativeTitleStyle,
|
|
18
20
|
pressWash,
|
|
19
21
|
useNativeControls,
|
|
@@ -58,6 +60,25 @@ const ITEM_PAD_RIGHT = ITEM_PAD;
|
|
|
58
60
|
// fit in.
|
|
59
61
|
const itemHeight = (fontSize) => capBand(fontSize) + ITEM_PAD * 2;
|
|
60
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
|
+
|
|
61
82
|
/**
|
|
62
83
|
* The menu's geometry: the palette's, or — where the trigger is a native
|
|
63
84
|
* popup bezel — NSMenu's, so the sheet that drops from it is the one the
|
|
@@ -228,6 +249,7 @@ function Option({
|
|
|
228
249
|
{
|
|
229
250
|
style: [
|
|
230
251
|
capTrim,
|
|
252
|
+
ONE_LINE,
|
|
231
253
|
{
|
|
232
254
|
color: active ? theme.hoverText : theme.text,
|
|
233
255
|
fontSize: metrics.fontSize,
|
|
@@ -438,7 +460,11 @@ export function Select({
|
|
|
438
460
|
if (open) scrollRef.current?.scrollIntoView(activeRef.current);
|
|
439
461
|
}, [open, activeIndex]);
|
|
440
462
|
|
|
441
|
-
|
|
463
|
+
// AppKit's own popup metrics, which the trigger below is laid out at —
|
|
464
|
+
// and only the trigger: the footprint it sits in is the caller's to size.
|
|
465
|
+
const nat = nativeControls ? bezelNatural(app, 'popup') : null;
|
|
466
|
+
|
|
467
|
+
const trigger = h(
|
|
442
468
|
'box',
|
|
443
469
|
{
|
|
444
470
|
theme,
|
|
@@ -465,7 +491,8 @@ export function Select({
|
|
|
465
491
|
flexDirection: 'row',
|
|
466
492
|
alignItems: 'center',
|
|
467
493
|
gap: 8,
|
|
468
|
-
|
|
494
|
+
...NATIVE_BAND,
|
|
495
|
+
height: nat.height,
|
|
469
496
|
paddingLeft: TRIGGER_PAD_LEFT,
|
|
470
497
|
paddingRight: 26,
|
|
471
498
|
// The title sits where NSPopUpButtonCell puts it — on the
|
|
@@ -515,7 +542,10 @@ export function Select({
|
|
|
515
542
|
':hover': { backgroundColor: theme.surfaceHover },
|
|
516
543
|
':active': { backgroundColor: theme.surfaceActive },
|
|
517
544
|
},
|
|
518
|
-
style,
|
|
545
|
+
// The caller's style, on the trigger where the trigger is the whole
|
|
546
|
+
// control. Under a native bezel it sizes the footprint below instead
|
|
547
|
+
// and the trigger keeps AppKit's height (`nativeFootprintStyle`).
|
|
548
|
+
!nativeControls && style,
|
|
519
549
|
],
|
|
520
550
|
},
|
|
521
551
|
// `pressed` while the menu is down: AppKit's popup answers being open
|
|
@@ -533,6 +563,7 @@ export function Select({
|
|
|
533
563
|
{
|
|
534
564
|
style: [
|
|
535
565
|
capTrim,
|
|
566
|
+
ONE_LINE,
|
|
536
567
|
nativeControls && nativeTitleStyle('regular'),
|
|
537
568
|
{ color: current ? theme.text : theme.textMuted },
|
|
538
569
|
],
|
|
@@ -635,4 +666,11 @@ export function Select({
|
|
|
635
666
|
),
|
|
636
667
|
),
|
|
637
668
|
);
|
|
669
|
+
|
|
670
|
+
// The bezel and its title are one unit at NSPopUpButton's height; the box
|
|
671
|
+
// around them is what a `height`, a `flexGrow` or a parent's align-stretch
|
|
672
|
+
// is free to make taller, with the control centred in it (issue #510).
|
|
673
|
+
return nativeControls
|
|
674
|
+
? h('box', { theme, style: [nativeFootprintStyle(nat), style] }, trigger)
|
|
675
|
+
: trigger;
|
|
638
676
|
}
|
package/src/components/native.js
CHANGED
|
@@ -101,6 +101,47 @@ export function nativeTitleStyle(controlSize = 'regular') {
|
|
|
101
101
|
return { alignSelf: 'flex-end', fontSize: CONTROL_FONT_SIZE[controlSize] };
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The **footprint** a native control sits in: the box the caller's style
|
|
106
|
+
* sizes, with the control — bezel, title and press wash, one unit at
|
|
107
|
+
* AppKit's own metrics — centred inside it.
|
|
108
|
+
*
|
|
109
|
+
* Two boxes rather than one, because the title is *placed* and not centred:
|
|
110
|
+
* it rides `TITLE_BASELINE` above the bezel body's bottom edge, and that is
|
|
111
|
+
* only where the cell puts it while the box is exactly `bezelNatural` tall.
|
|
112
|
+
* The caller's style is applied last and flex stretches a box regardless, so
|
|
113
|
+
* it was not always: `style={{ height: 44 }}`, a `height: '100%'` in a taller
|
|
114
|
+
* parent and a bare `flexGrow: 1` each made the box taller, and the two
|
|
115
|
+
* halves — the bezel absolutely filling it, the title glued to its bottom —
|
|
116
|
+
* came apart, the label landing below the control it names (issue #510). The
|
|
117
|
+
* drawn path never had this, because it centres its label: a stretched drawn
|
|
118
|
+
* control is merely roomy, where a stretched native one was broken.
|
|
119
|
+
*
|
|
120
|
+
* So the height a caller can move belongs to a box the control sits in, and
|
|
121
|
+
* the control keeps AppKit's. The default here is exactly today's box — an
|
|
122
|
+
* untouched control lays out as it always did, and the explicit height still
|
|
123
|
+
* resists a row's align-stretch — and the caller's style, applied after it,
|
|
124
|
+
* moves the footprint alone. The control stretches across it (align-stretch,
|
|
125
|
+
* the default), so a footprint given a width is a bezel that wide.
|
|
126
|
+
*
|
|
127
|
+
* The control, not the footprint, stays the *control*: the role, the
|
|
128
|
+
* handlers, the focus ring and the ref go on it, so a press in the slack
|
|
129
|
+
* above a 22pt popup in a 46pt hole does nothing at all — as it does in
|
|
130
|
+
* AppKit — rather than firing a button with no visible answer to the press.
|
|
131
|
+
*/
|
|
132
|
+
export function nativeFootprintStyle(nat) {
|
|
133
|
+
return { height: nat.height, justifyContent: 'center' };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The rest of what a native control's own box says about its size: nothing
|
|
138
|
+
* shrinks it. A squashed bezel is as wrong as a stretched one, and a
|
|
139
|
+
* footprint shorter than the control is a caller asking for something the
|
|
140
|
+
* cell's metrics cannot answer — so it overflows, visibly, rather than
|
|
141
|
+
* quietly drawing a title where the bezel is not.
|
|
142
|
+
*/
|
|
143
|
+
export const NATIVE_BAND = Object.freeze({ flexShrink: 0 });
|
|
144
|
+
|
|
104
145
|
/**
|
|
105
146
|
* NSMenu's geometry, for the menu a native popup bezel opens — read off
|
|
106
147
|
* `NSMenu.size` on macOS 15 rather than off a screenshot, so it is the
|