basalt-appkit 0.0.0-rc5 → 0.1.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/native/CMakeLists.txt +16 -2
- package/native/appkit/AppKitAppWindow.h +24 -0
- package/native/appkit/AppKitImageLoader.h +48 -1
- package/native/appkit/AppKitImageLoader.mm +228 -10
- package/native/appkit/AppKitMenuBar.mm +58 -0
- package/native/appkit/AppKitMountingManager.h +34 -0
- package/native/appkit/AppKitMountingManager.mm +576 -9
- package/native/appkit/AppKitScrollView.h +48 -0
- package/native/appkit/AppKitScrollView.mm +210 -0
- package/native/appkit/AppKitTextInput.h +38 -1
- package/native/appkit/AppKitTextInput.mm +235 -8
- package/native/appkit/AppKitTextPeer.h +60 -0
- package/native/appkit/AppKitTextPeer.mm +307 -0
- package/native/appkit/AppKitTouchDispatcher.h +56 -0
- package/native/appkit/AppKitTouchDispatcher.mm +140 -0
- package/native/appkit/AppKitWindowControl.mm +38 -3
- package/native/appkit/ComponentRegistryAppKit.mm +20 -0
- package/native/appkit/CoreTextLayout.h +64 -2
- package/native/appkit/CoreTextLayout.mm +431 -71
- package/native/appkit/CoreTextLayoutManager.mm +57 -6
- package/native/appkit/FontRegistryCoreText.mm +6 -5
- package/native/appkit/PlatformServicesAppKit.mm +101 -20
- package/native/appkit/RnAppKitView.h +492 -0
- package/native/appkit/RnAppKitView.mm +2266 -23
- package/native/appkit/RnTextLayout.h +106 -0
- package/native/appkit/RnTextLayout.mm +321 -2
- package/native/appkit/main_appkit.mm +174 -3
- package/native/tests/test_appkit_accessibility.mm +289 -0
- package/native/tests/test_appkit_borders.mm +975 -0
- package/native/tests/test_appkit_fonts.mm +203 -0
- package/native/tests/test_appkit_image.mm +392 -17
- package/native/tests/test_appkit_input.mm +209 -0
- package/native/tests/test_appkit_mounting.mm +360 -0
- package/native/tests/test_appkit_scrollbar.mm +125 -0
- package/native/tests/test_appkit_scrollview.mm +371 -0
- package/native/tests/test_appkit_selection.mm +349 -0
- package/native/tests/test_appkit_services.mm +51 -1
- package/native/tests/test_appkit_text.mm +1628 -0
- package/native/tests/test_appkit_textinput.mm +819 -0
- package/native/tests/test_appkit_titlebar.mm +43 -0
- package/native/tests/test_appkit_view.mm +609 -0
- package/package.json +4 -3
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
#import "CoreTextLayout.h"
|
|
2
2
|
|
|
3
|
+
#include "TextAlignments.h"
|
|
4
|
+
#include "TextDirection.h"
|
|
5
|
+
#include "WritingDirections.h"
|
|
6
|
+
|
|
3
7
|
#include "FontRegistry.h"
|
|
8
|
+
#include "FontScaling.h"
|
|
9
|
+
#include "FontVariants.h"
|
|
10
|
+
#include "TextColors.h"
|
|
11
|
+
#include "TextDecorations.h"
|
|
4
12
|
|
|
5
13
|
#include <react/renderer/graphics/Color.h>
|
|
6
14
|
|
|
@@ -52,45 +60,82 @@ CGFloat toCoreTextWeight(FontWeight weight) {
|
|
|
52
60
|
return NSFontWeightRegular;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
|
-
|
|
63
|
+
// An edge, spelled as AppKit spells it. The relative alignments are gone by
|
|
64
|
+
// here -- core/TextAlignments.h resolved them against the paragraph's direction
|
|
65
|
+
// -- so what arrives is one of four.
|
|
66
|
+
//
|
|
67
|
+
// `NSTextAlignmentNatural` is deliberately not in the answers: it is the style
|
|
68
|
+
// default and stays in place for a paragraph that asked for `natural` or asked
|
|
69
|
+
// for nothing, which is what lets an `NSTextField` resolve a field's own
|
|
70
|
+
// direction. Folding `left` into it is what made `textAlign: 'left'` draw
|
|
71
|
+
// flush right in a right-to-left paragraph.
|
|
72
|
+
NSTextAlignment toTextAlignment(basalt::PhysicalTextAlignment alignment) {
|
|
56
73
|
switch (alignment) {
|
|
57
|
-
case
|
|
74
|
+
case basalt::PhysicalTextAlignment::Center:
|
|
58
75
|
return NSTextAlignmentCenter;
|
|
59
|
-
case
|
|
60
|
-
#if BASALT_RN_MINOR >= 87
|
|
61
|
-
case TextAlignment::End:
|
|
62
|
-
#endif
|
|
76
|
+
case basalt::PhysicalTextAlignment::Right:
|
|
63
77
|
return NSTextAlignmentRight;
|
|
64
|
-
case
|
|
78
|
+
case basalt::PhysicalTextAlignment::Justified:
|
|
65
79
|
return NSTextAlignmentJustified;
|
|
66
|
-
case
|
|
67
|
-
#if BASALT_RN_MINOR >= 87
|
|
68
|
-
case TextAlignment::Start:
|
|
69
|
-
#endif
|
|
70
|
-
case TextAlignment::Natural:
|
|
80
|
+
case basalt::PhysicalTextAlignment::Left:
|
|
71
81
|
break;
|
|
72
82
|
}
|
|
73
|
-
return
|
|
83
|
+
return NSTextAlignmentLeft;
|
|
74
84
|
}
|
|
75
85
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
alpha:components.alpha];
|
|
86
|
+
// A colour core resolved, in sRGB. Explicitly sRGB for the same reason
|
|
87
|
+
// RnAppKitView builds its background that way: a device colour space shifts
|
|
88
|
+
// every colour on a wide-gamut display, and text that does not match Linux is
|
|
89
|
+
// the bug this project is least able to afford.
|
|
90
|
+
NSColor *toColor(const basalt::TextColor &color) {
|
|
91
|
+
return [NSColor colorWithSRGBRed:color.red
|
|
92
|
+
green:color.green
|
|
93
|
+
blue:color.blue
|
|
94
|
+
alpha:color.alpha];
|
|
86
95
|
}
|
|
87
96
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
97
|
+
// React Native's five decoration styles against Core Text's bitmask.
|
|
98
|
+
//
|
|
99
|
+
// A style and a pattern are OR'd into one value: `NSUnderlineStyleSingle` with
|
|
100
|
+
// `NSUnderlineStylePatternDot` is the dotted line CSS means. There is no wavy
|
|
101
|
+
// pattern -- the squiggle under a misspelling is drawn by the text system's
|
|
102
|
+
// spell checking and not by an underline style -- so `wavy` falls back to a
|
|
103
|
+
// single line here and is exact on GTK, which is the opposite way round from
|
|
104
|
+
// dotted and dashed. core/TextDecorations.h says why falling back beats
|
|
105
|
+
// refusing, and backlog/text.md records both gaps.
|
|
106
|
+
NSUnderlineStyle toUnderlineStyle(facebook::react::TextDecorationStyle style) {
|
|
107
|
+
switch (style) {
|
|
108
|
+
case facebook::react::TextDecorationStyle::Double:
|
|
109
|
+
return NSUnderlineStyleDouble;
|
|
110
|
+
case facebook::react::TextDecorationStyle::Dotted:
|
|
111
|
+
return static_cast<NSUnderlineStyle>(NSUnderlineStyleSingle | NSUnderlineStylePatternDot);
|
|
112
|
+
case facebook::react::TextDecorationStyle::Dashed:
|
|
113
|
+
return static_cast<NSUnderlineStyle>(NSUnderlineStyleSingle | NSUnderlineStylePatternDash);
|
|
114
|
+
#if BASALT_RN_MINOR >= 87
|
|
115
|
+
// `wavy` arrived in 0.87; 0.86's enum has four members and naming the
|
|
116
|
+
// fifth does not compile there. The squiggle itself is drawn rather than
|
|
117
|
+
// asked for, which is why this falls through to a single line: see
|
|
118
|
+
// RnWavyUnderlineAttributeName.
|
|
119
|
+
case facebook::react::TextDecorationStyle::Wavy:
|
|
120
|
+
#endif
|
|
121
|
+
case facebook::react::TextDecorationStyle::Solid:
|
|
122
|
+
return NSUnderlineStyleSingle;
|
|
93
123
|
}
|
|
124
|
+
return NSUnderlineStyleSingle;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
NSFont *fontFor(const TextAttributes &textAttributes, basalt::FontFit fit = {}) {
|
|
128
|
+
// Through core/FontScaling.h, which is where `allowFontScaling` and
|
|
129
|
+
// `maxFontSizeMultiplier` are honoured. macOS reports no text scale to honour
|
|
130
|
+
// -- see appKitTextScale() -- so on this host those two props can only clamp
|
|
131
|
+
// a multiplier something else set, and the test that proves they are read
|
|
132
|
+
// supplies one with BASALT_TEST_FONT_SCALE.
|
|
133
|
+
// `adjustsFontSizeToFit` last, over the size those props resolved to:
|
|
134
|
+
// shrinking to fit is about the size the text would otherwise have been,
|
|
135
|
+
// including the desktop's own scale. A paragraph that never asked carries a
|
|
136
|
+
// ratio of 1 and this changes nothing.
|
|
137
|
+
const float size = static_cast<float>(fit.apply(
|
|
138
|
+
basalt::effectiveFontSize(textAttributes, kDefaultFontSize, basalt::systemFontScale())));
|
|
94
139
|
|
|
95
140
|
const CGFloat weight = textAttributes.fontWeight ? toCoreTextWeight(*textAttributes.fontWeight)
|
|
96
141
|
: NSFontWeightRegular;
|
|
@@ -133,14 +178,55 @@ NSFont *fontFor(const TextAttributes &textAttributes) {
|
|
|
133
178
|
}
|
|
134
179
|
}
|
|
135
180
|
|
|
181
|
+
// `fontVariant`, which on this platform is a font and not an attribute: Core
|
|
182
|
+
// Text asks for features on the descriptor, so the font has to be rebuilt.
|
|
183
|
+
// Last, so the features are added to whatever family and slant was settled
|
|
184
|
+
// on above.
|
|
185
|
+
NSArray *const features = fontFeaturesFor(textAttributes);
|
|
186
|
+
if (features.count > 0) {
|
|
187
|
+
NSFontDescriptor *featured = [font.fontDescriptor fontDescriptorByAddingAttributes:@{
|
|
188
|
+
NSFontFeatureSettingsAttribute : features,
|
|
189
|
+
}];
|
|
190
|
+
NSFont *varied = [NSFont fontWithDescriptor:featured size:size];
|
|
191
|
+
if (varied != nil) {
|
|
192
|
+
font = varied;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
136
196
|
return font;
|
|
137
197
|
}
|
|
138
198
|
|
|
139
|
-
NSParagraphStyle *paragraphStyleFor(const TextAttributes &textAttributes
|
|
199
|
+
NSParagraphStyle *paragraphStyleFor(const TextAttributes &textAttributes,
|
|
200
|
+
basalt::TextDirections directions) {
|
|
140
201
|
NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init];
|
|
141
202
|
|
|
142
|
-
|
|
143
|
-
|
|
203
|
+
// `natural` is left alone rather than resolved here: it is this style's own
|
|
204
|
+
// default, and leaving it is what lets a frame -- or an NSTextField, which
|
|
205
|
+
// has its own text and its own direction -- resolve it. RnTextLayout resolves
|
|
206
|
+
// it for the paragraph path, where no frame is used; see its `rightToLeft`.
|
|
207
|
+
if (textAttributes.alignment && *textAttributes.alignment != TextAlignment::Natural) {
|
|
208
|
+
style.alignment =
|
|
209
|
+
toTextAlignment(basalt::physicalTextAlignment(*textAttributes.alignment, directions));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// `baseWritingDirection`, which is the same call upstream's iOS half makes in
|
|
213
|
+
// `RCTAttributedTextUtils.mm`. `Natural` is the paragraph style's own default
|
|
214
|
+
// and means the Unicode bidi algorithm decides from the first strong
|
|
215
|
+
// character, so it is set explicitly rather than skipped: a nested <Text>
|
|
216
|
+
// inherits this style, and leaving it unset would let the enclosing
|
|
217
|
+
// paragraph's direction stand where the app asked for the natural one.
|
|
218
|
+
if (textAttributes.baseWritingDirection) {
|
|
219
|
+
switch (*textAttributes.baseWritingDirection) {
|
|
220
|
+
case facebook::react::WritingDirection::Natural:
|
|
221
|
+
style.baseWritingDirection = NSWritingDirectionNatural;
|
|
222
|
+
break;
|
|
223
|
+
case facebook::react::WritingDirection::LeftToRight:
|
|
224
|
+
style.baseWritingDirection = NSWritingDirectionLeftToRight;
|
|
225
|
+
break;
|
|
226
|
+
case facebook::react::WritingDirection::RightToLeft:
|
|
227
|
+
style.baseWritingDirection = NSWritingDirectionRightToLeft;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
144
230
|
}
|
|
145
231
|
|
|
146
232
|
// React Native's lineHeight is the total line box height, and setting both
|
|
@@ -153,9 +239,11 @@ NSParagraphStyle *paragraphStyleFor(const TextAttributes &textAttributes) {
|
|
|
153
239
|
style.maximumLineHeight = lineHeight;
|
|
154
240
|
}
|
|
155
241
|
|
|
156
|
-
// Wrapping, always
|
|
157
|
-
// a truncating line break mode
|
|
158
|
-
//
|
|
242
|
+
// Wrapping, always, *in the style*. The line limit and the cut are applied
|
|
243
|
+
// in RnTextLayout rather than here: a truncating line break mode on the
|
|
244
|
+
// paragraph style would make Core Text ellipsize each line it lays out
|
|
245
|
+
// rather than the last one kept. `lineBreakMode` is honoured where that
|
|
246
|
+
// limit is set, in buildTextLayout below.
|
|
159
247
|
style.lineBreakMode = NSLineBreakByWordWrapping;
|
|
160
248
|
|
|
161
249
|
return style;
|
|
@@ -163,55 +251,257 @@ NSParagraphStyle *paragraphStyleFor(const TextAttributes &textAttributes) {
|
|
|
163
251
|
|
|
164
252
|
} // namespace
|
|
165
253
|
|
|
166
|
-
|
|
254
|
+
float appKitTextScale() { return 1.0F; }
|
|
255
|
+
|
|
256
|
+
// `fontVariant` in Core Text's vocabulary, which is not OpenType's.
|
|
257
|
+
//
|
|
258
|
+
// Pango and DirectWrite take the four-character tags core/FontVariants.h hands
|
|
259
|
+
// out; Core Text takes Apple's older AAT pair, a feature type and a selector
|
|
260
|
+
// inside it. This is upstream's own table, from `RCTFontFeatures` in
|
|
261
|
+
// RCTFontUtils.mm, which is where to look if a variant ever renders differently
|
|
262
|
+
// from iOS.
|
|
263
|
+
//
|
|
264
|
+
// The twenty stylistic alternates are a formula rather than twenty entries: the
|
|
265
|
+
// on-selectors run 2, 4, 6 and so on, which the suite asserts against the SDK's
|
|
266
|
+
// own `kStylisticAltOneOnSelector` and `kStylisticAltTwentyOnSelector` rather
|
|
267
|
+
// than taking this comment's word for it.
|
|
268
|
+
NSArray *fontFeaturesFor(const TextAttributes &textAttributes) {
|
|
269
|
+
NSMutableArray *features = [NSMutableArray array];
|
|
270
|
+
for (const auto variant : basalt::fontVariants(textAttributes)) {
|
|
271
|
+
int type = 0;
|
|
272
|
+
int selector = 0;
|
|
273
|
+
switch (variant) {
|
|
274
|
+
case facebook::react::FontVariant::SmallCaps:
|
|
275
|
+
type = kLowerCaseType;
|
|
276
|
+
selector = kLowerCaseSmallCapsSelector;
|
|
277
|
+
break;
|
|
278
|
+
case facebook::react::FontVariant::OldstyleNums:
|
|
279
|
+
type = kNumberCaseType;
|
|
280
|
+
selector = kLowerCaseNumbersSelector;
|
|
281
|
+
break;
|
|
282
|
+
case facebook::react::FontVariant::LiningNums:
|
|
283
|
+
type = kNumberCaseType;
|
|
284
|
+
selector = kUpperCaseNumbersSelector;
|
|
285
|
+
break;
|
|
286
|
+
case facebook::react::FontVariant::TabularNums:
|
|
287
|
+
type = kNumberSpacingType;
|
|
288
|
+
selector = kMonospacedNumbersSelector;
|
|
289
|
+
break;
|
|
290
|
+
case facebook::react::FontVariant::ProportionalNums:
|
|
291
|
+
type = kNumberSpacingType;
|
|
292
|
+
selector = kProportionalNumbersSelector;
|
|
293
|
+
break;
|
|
294
|
+
case facebook::react::FontVariant::Default:
|
|
295
|
+
continue;
|
|
296
|
+
default: {
|
|
297
|
+
// A stylistic alternate, which the tag says which of: `ss07` is the
|
|
298
|
+
// seventh, and its on-selector is twice that.
|
|
299
|
+
const char *const tag = basalt::openTypeTag(variant);
|
|
300
|
+
if (tag == nullptr || tag[0] != 's' || tag[1] != 's') {
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
const int number = (tag[2] - '0') * 10 + (tag[3] - '0');
|
|
304
|
+
type = kStylisticAlternativesType;
|
|
305
|
+
selector = number * 2;
|
|
306
|
+
break;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
[features addObject:@{
|
|
310
|
+
NSFontFeatureTypeIdentifierKey : @(type),
|
|
311
|
+
NSFontFeatureSelectorIdentifierKey : @(selector),
|
|
312
|
+
}];
|
|
313
|
+
}
|
|
314
|
+
return features;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
NSDictionary<NSAttributedStringKey, id> *buildTextAttributes(const TextAttributes &textAttributes,
|
|
318
|
+
basalt::FontFit fit,
|
|
319
|
+
basalt::TextDirections directions) {
|
|
167
320
|
NSMutableDictionary<NSAttributedStringKey, id> *attributes = [NSMutableDictionary dictionary];
|
|
168
321
|
|
|
169
|
-
attributes[NSFontAttributeName] = fontFor(textAttributes);
|
|
170
|
-
attributes[NSParagraphStyleAttributeName] = paragraphStyleFor(textAttributes);
|
|
322
|
+
attributes[NSFontAttributeName] = fontFor(textAttributes, fit);
|
|
323
|
+
attributes[NSParagraphStyleAttributeName] = paragraphStyleFor(textAttributes, directions);
|
|
171
324
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
// platform is the right call here: the same app has to look the same on
|
|
179
|
-
// Linux.
|
|
180
|
-
attributes[NSForegroundColorAttributeName] = [NSColor colorWithSRGBRed:0 green:0 blue:0 alpha:1];
|
|
181
|
-
}
|
|
325
|
+
// The foreground, with `opacity` multiplied in, and React Native's default of
|
|
326
|
+
// opaque black rather than the system label colour -- which is white in dark
|
|
327
|
+
// mode and would make default text invisible on a light background. Both
|
|
328
|
+
// rules are core/TextColors.h's now, so GTK cannot drift from them.
|
|
329
|
+
attributes[NSForegroundColorAttributeName] =
|
|
330
|
+
toColor(basalt::textForegroundColor(textAttributes));
|
|
182
331
|
|
|
183
|
-
if (textAttributes
|
|
184
|
-
attributes[NSBackgroundColorAttributeName] = toColor(
|
|
332
|
+
if (const auto background = basalt::textBackgroundColor(textAttributes)) {
|
|
333
|
+
attributes[NSBackgroundColorAttributeName] = toColor(*background);
|
|
185
334
|
}
|
|
186
335
|
|
|
187
336
|
if (isSet(textAttributes.letterSpacing)) {
|
|
188
337
|
attributes[NSKernAttributeName] = @(static_cast<CGFloat>(textAttributes.letterSpacing));
|
|
189
338
|
}
|
|
190
339
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
340
|
+
// Which lines, in which colour, in which style: resolved in
|
|
341
|
+
// core/TextDecorations.h so both hosts agree about what was asked for, and
|
|
342
|
+
// mapped here to Core Text's one bitmask.
|
|
343
|
+
if (const auto decoration = basalt::textDecoration(textAttributes)) {
|
|
344
|
+
const NSUnderlineStyle style = toUnderlineStyle(decoration->style);
|
|
345
|
+
#if BASALT_RN_MINOR >= 87
|
|
346
|
+
const bool wavy = decoration->style == facebook::react::TextDecorationStyle::Wavy;
|
|
347
|
+
#else
|
|
348
|
+
// 0.86's enum has no `wavy`, so nothing can ask for one there.
|
|
349
|
+
const bool wavy = false;
|
|
350
|
+
#endif
|
|
351
|
+
if (decoration->underline && wavy) {
|
|
352
|
+
// Core Text has no wavy pattern, so asking for an underline here would
|
|
353
|
+
// draw a straight line through the squiggle the layout is about to
|
|
354
|
+
// stroke. The colour travels on the attribute: the decoration's when the
|
|
355
|
+
// app named one, and the text's own otherwise, which is what every
|
|
356
|
+
// undecorated underline does. See RnWavyUnderlineAttributeName.
|
|
357
|
+
NSColor *lineColor = decoration->hasColor
|
|
358
|
+
? toColor(basalt::TextColor{decoration->red, decoration->green, decoration->blue,
|
|
359
|
+
decoration->alpha})
|
|
360
|
+
: attributes[NSForegroundColorAttributeName];
|
|
361
|
+
attributes[RnWavyUnderlineAttributeName] =
|
|
362
|
+
lineColor != nil ? lineColor : [NSColor blackColor];
|
|
363
|
+
} else if (decoration->underline) {
|
|
364
|
+
attributes[NSUnderlineStyleAttributeName] = @(style);
|
|
365
|
+
}
|
|
366
|
+
if (decoration->strikethrough) {
|
|
367
|
+
// The same bitmask as an underline, which is one thing AppKit has over
|
|
368
|
+
// Pango here: a dotted strikethrough is a dotted strikethrough.
|
|
369
|
+
attributes[NSStrikethroughStyleAttributeName] = @(style);
|
|
370
|
+
}
|
|
371
|
+
if (decoration->hasColor) {
|
|
372
|
+
NSColor *const color = toColor(basalt::TextColor{
|
|
373
|
+
decoration->red, decoration->green, decoration->blue, decoration->alpha});
|
|
374
|
+
if (decoration->underline && !wavy) {
|
|
375
|
+
attributes[NSUnderlineColorAttributeName] = color;
|
|
376
|
+
}
|
|
377
|
+
if (decoration->strikethrough) {
|
|
378
|
+
attributes[NSStrikethroughColorAttributeName] = color;
|
|
379
|
+
}
|
|
205
380
|
}
|
|
206
381
|
}
|
|
207
382
|
|
|
208
383
|
return attributes;
|
|
209
384
|
}
|
|
210
385
|
|
|
386
|
+
// The callbacks a run delegate answers with. CoreText asks for three numbers and
|
|
387
|
+
// the box is anchored on the baseline, so the whole height is ascent and the
|
|
388
|
+
// descent is zero, which puts the view sitting on the line rather than straddling
|
|
389
|
+
// it. That matches the shape attribute on GTK, where the rect runs from -height
|
|
390
|
+
// to 0.
|
|
391
|
+
//
|
|
392
|
+
// The context is a heap-allocated Size owned by the delegate: `dealloc` frees it,
|
|
393
|
+
// and CoreText guarantees it is called once the delegate goes.
|
|
394
|
+
CGFloat attachmentAscent(void *context) {
|
|
395
|
+
return static_cast<CGFloat>(static_cast<facebook::react::Size *>(context)->height);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
CGFloat attachmentDescent(void * /*context*/) {
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
CGFloat attachmentWidth(void *context) {
|
|
403
|
+
return static_cast<CGFloat>(static_cast<facebook::react::Size *>(context)->width);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
void attachmentDealloc(void *context) {
|
|
407
|
+
delete static_cast<facebook::react::Size *>(context);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
CTRunDelegateRef makeAttachmentDelegate(const facebook::react::Size &size) {
|
|
411
|
+
CTRunDelegateCallbacks callbacks = {};
|
|
412
|
+
callbacks.version = kCTRunDelegateCurrentVersion;
|
|
413
|
+
callbacks.dealloc = attachmentDealloc;
|
|
414
|
+
callbacks.getAscent = attachmentAscent;
|
|
415
|
+
callbacks.getDescent = attachmentDescent;
|
|
416
|
+
callbacks.getWidth = attachmentWidth;
|
|
417
|
+
return CTRunDelegateCreate(&callbacks, new facebook::react::Size{size});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// `textTransform`, applied before anything measures or draws the text.
|
|
421
|
+
//
|
|
422
|
+
// NSString's case mapping rather than a shared one, for the reason the GTK host
|
|
423
|
+
// uses GLib's: Unicode case mapping is the toolkit's, and a byte-wise transform
|
|
424
|
+
// would leave ß and every accented letter alone while looking right in English.
|
|
425
|
+
// The two hosts are asserted against the same cases.
|
|
426
|
+
//
|
|
427
|
+
// `capitalize` is React Native's own rule, from RCTAttributedTextUtils.mm: split
|
|
428
|
+
// on single spaces, and a word whose first character is not a digit is
|
|
429
|
+
// capitalised -- which lowercases the rest of it, so "iOS" becomes "Ios". That
|
|
430
|
+
// is surprising and is what the other platforms do.
|
|
431
|
+
NSString *transformedFragmentText(const AttributedString::Fragment &fragment, NSString *text) {
|
|
432
|
+
const auto transform = fragment.textAttributes.textTransform;
|
|
433
|
+
if (!transform.has_value()) {
|
|
434
|
+
return text;
|
|
435
|
+
}
|
|
436
|
+
switch (*transform) {
|
|
437
|
+
case facebook::react::TextTransform::Uppercase:
|
|
438
|
+
return text.uppercaseString;
|
|
439
|
+
case facebook::react::TextTransform::Lowercase:
|
|
440
|
+
return text.lowercaseString;
|
|
441
|
+
case facebook::react::TextTransform::Capitalize: {
|
|
442
|
+
NSMutableArray<NSString *> *words = [NSMutableArray array];
|
|
443
|
+
for (NSString *word in [text componentsSeparatedByString:@" "]) {
|
|
444
|
+
if (word.length == 0) {
|
|
445
|
+
[words addObject:word];
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
NSRange first = [word rangeOfComposedCharacterSequenceAtIndex:0];
|
|
449
|
+
NSString *lead = [word substringWithRange:first];
|
|
450
|
+
const BOOL isDigit = [lead rangeOfCharacterFromSet:NSCharacterSet.decimalDigitCharacterSet]
|
|
451
|
+
.location != NSNotFound;
|
|
452
|
+
[words addObject:isDigit ? word.lowercaseString : word.capitalizedString];
|
|
453
|
+
}
|
|
454
|
+
return [words componentsJoinedByString:@" "];
|
|
455
|
+
}
|
|
456
|
+
case facebook::react::TextTransform::None:
|
|
457
|
+
case facebook::react::TextTransform::Unset:
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
return text;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
basalt::FontFit textFitScale(const AttributedString &attributedString,
|
|
464
|
+
const ParagraphAttributes ¶graphAttributes,
|
|
465
|
+
float maxWidth,
|
|
466
|
+
float maxHeight) {
|
|
467
|
+
const basalt::FontFit limits = basalt::fontFitLimits(paragraphAttributes);
|
|
468
|
+
if (!paragraphAttributes.adjustsFontSizeToFit) {
|
|
469
|
+
return limits;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Measured against the paragraph *without* its line limit, which is how this
|
|
473
|
+
// prop and `numberOfLines` work together. iOS tests whether the text was
|
|
474
|
+
// truncated; asking what the untruncated paragraph needs answers the same
|
|
475
|
+
// question with one layout instead of two, and is the reason a one-line
|
|
476
|
+
// button label shrinks rather than ellipsising.
|
|
477
|
+
ParagraphAttributes untruncated = paragraphAttributes;
|
|
478
|
+
untruncated.maximumNumberOfLines = 0;
|
|
479
|
+
|
|
480
|
+
return basalt::fontFitToBox(
|
|
481
|
+
limits,
|
|
482
|
+
[&](double ratio) {
|
|
483
|
+
basalt::FontFit probe = limits;
|
|
484
|
+
probe.ratio = ratio;
|
|
485
|
+
RnTextLayout *layout = buildTextLayout(attributedString, untruncated, probe);
|
|
486
|
+
const CGSize size = [layout sizeForWidth:maxWidth];
|
|
487
|
+
return basalt::FontFitBox{.width = size.width, .height = size.height};
|
|
488
|
+
},
|
|
489
|
+
basalt::FontFitBox{.width = maxWidth, .height = maxHeight});
|
|
490
|
+
}
|
|
491
|
+
|
|
211
492
|
RnTextLayout *buildTextLayout(const AttributedString &attributedString,
|
|
212
|
-
const ParagraphAttributes ¶graphAttributes
|
|
493
|
+
const ParagraphAttributes ¶graphAttributes,
|
|
494
|
+
basalt::FontFit fit) {
|
|
213
495
|
NSMutableAttributedString *string = [[NSMutableAttributedString alloc] init];
|
|
214
496
|
|
|
497
|
+
// Which way the paragraph runs, once for the whole of it: the prop when the
|
|
498
|
+
// app set one, and Unicode's rule P2 over the text when it said `natural` or
|
|
499
|
+
// said nothing. Every relative alignment needs it, and Core Text will not
|
|
500
|
+
// answer it here -- it resolves a direction inside a frame, and this layer
|
|
501
|
+
// draws its own lines. See core/TextDirection.h.
|
|
502
|
+
const basalt::TextDirections directions = basalt::paragraphDirections(attributedString);
|
|
503
|
+
const bool rightToLeft = directions.paragraph;
|
|
504
|
+
|
|
215
505
|
for (const auto &fragment : attributedString.getFragments()) {
|
|
216
506
|
if (fragment.string.empty()) {
|
|
217
507
|
continue;
|
|
@@ -220,9 +510,29 @@ RnTextLayout *buildTextLayout(const AttributedString &attributedString,
|
|
|
220
510
|
if (text == nil) {
|
|
221
511
|
continue;
|
|
222
512
|
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
513
|
+
text = transformedFragmentText(fragment, text);
|
|
514
|
+
|
|
515
|
+
NSMutableDictionary *attributes =
|
|
516
|
+
[buildTextAttributes(fragment.textAttributes, fit, directions) mutableCopy];
|
|
517
|
+
|
|
518
|
+
// An inline `<View>`: one fragment holding U+FFFC, whose own size React
|
|
519
|
+
// Native has already measured into the fragment. A run delegate is
|
|
520
|
+
// CoreText's way to say "this character is this big", the counterpart of
|
|
521
|
+
// Pango's shape attribute on the other host. Without it the character
|
|
522
|
+
// reserves whatever the font gives a missing glyph and the view is reported
|
|
523
|
+
// at zero. See backlog/text.md.
|
|
524
|
+
if (fragment.isAttachment()) {
|
|
525
|
+
const auto &size = fragment.parentShadowView.layoutMetrics.frame.size;
|
|
526
|
+
if (size.width > 0 && size.height > 0) {
|
|
527
|
+
if (CTRunDelegateRef delegate = makeAttachmentDelegate(size)) {
|
|
528
|
+
attributes[(__bridge NSString *)kCTRunDelegateAttributeName] =
|
|
529
|
+
(__bridge_transfer id)delegate;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
[string appendAttributedString:[[NSAttributedString alloc] initWithString:text
|
|
535
|
+
attributes:attributes]];
|
|
226
536
|
}
|
|
227
537
|
|
|
228
538
|
CTLineTruncationType truncation = kCTLineTruncationEnd;
|
|
@@ -243,10 +553,60 @@ RnTextLayout *buildTextLayout(const AttributedString &attributedString,
|
|
|
243
553
|
break;
|
|
244
554
|
}
|
|
245
555
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
556
|
+
// `lineBreakMode`, which React Native's JavaScript spells `lineBreakModeIOS`
|
|
557
|
+
// and which this host had been ignoring: the paragraph style below sets
|
|
558
|
+
// word wrapping unconditionally, and Core Text takes its truncation from
|
|
559
|
+
// this layer rather than from the style. Four of the six modes are a cut
|
|
560
|
+
// rather than a wrap, and each names the end the ellipsis goes at.
|
|
561
|
+
//
|
|
562
|
+
// `numberOfLines` wins over it when an app set both, which is what iOS does:
|
|
563
|
+
// the count is that prop's question and the end to cut is this one's. `char`
|
|
564
|
+
// is the one mode left unhonoured -- wrapping between characters is the
|
|
565
|
+
// typesetter's business rather than the framesetter's -- and
|
|
566
|
+
// backlog/text.md records it.
|
|
567
|
+
NSInteger lineLimit = paragraphAttributes.maximumNumberOfLines;
|
|
568
|
+
const auto lineBreak = attributedString.getFragments().empty()
|
|
569
|
+
? std::optional<facebook::react::LineBreakMode>{}
|
|
570
|
+
: attributedString.getFragments().front().textAttributes.lineBreakMode;
|
|
571
|
+
if (lineBreak.has_value()) {
|
|
572
|
+
bool cuts = true;
|
|
573
|
+
switch (*lineBreak) {
|
|
574
|
+
case facebook::react::LineBreakMode::Head:
|
|
575
|
+
truncation = kCTLineTruncationStart;
|
|
576
|
+
truncates = true;
|
|
577
|
+
break;
|
|
578
|
+
case facebook::react::LineBreakMode::Middle:
|
|
579
|
+
truncation = kCTLineTruncationMiddle;
|
|
580
|
+
truncates = true;
|
|
581
|
+
break;
|
|
582
|
+
case facebook::react::LineBreakMode::Tail:
|
|
583
|
+
truncation = kCTLineTruncationEnd;
|
|
584
|
+
truncates = true;
|
|
585
|
+
break;
|
|
586
|
+
case facebook::react::LineBreakMode::Clip:
|
|
587
|
+
truncates = false;
|
|
588
|
+
break;
|
|
589
|
+
case facebook::react::LineBreakMode::Char:
|
|
590
|
+
case facebook::react::LineBreakMode::Word:
|
|
591
|
+
cuts = false;
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
if (cuts && lineLimit <= 0) {
|
|
595
|
+
lineLimit = 1;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
RnTextLayout *layout =
|
|
600
|
+
[RnTextLayout layoutWithAttributedString:string
|
|
601
|
+
maximumNumberOfLines:lineLimit
|
|
602
|
+
truncationType:truncation
|
|
603
|
+
truncates:truncates ? YES : NO];
|
|
604
|
+
layout.rightToLeft = rightToLeft ? YES : NO;
|
|
605
|
+
// `textAlignVertical`, which is where the paragraph sits in a box taller than
|
|
606
|
+
// it is. Applied while drawing, the box being known only then.
|
|
607
|
+
layout.verticalFlush =
|
|
608
|
+
basalt::textVerticalFlushFactor(paragraphAttributes.textAlignVertical);
|
|
609
|
+
return layout;
|
|
250
610
|
}
|
|
251
611
|
|
|
252
612
|
} // namespace basalt
|
|
@@ -99,7 +99,16 @@ TextMeasurement TextLayoutManager::measure(const AttributedStringBox &attributed
|
|
|
99
99
|
? -1.0
|
|
100
100
|
: static_cast<CGFloat>(layoutConstraints.maximumSize.width);
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
// `adjustsFontSizeToFit`: how far the fonts have to shrink for this
|
|
103
|
+
// paragraph to fit the box Yoga offered. A no-op ratio of 1 for every
|
|
104
|
+
// paragraph that did not ask, which is almost all of them -- the search
|
|
105
|
+
// builds a layout per probe and must not run otherwise.
|
|
106
|
+
const basalt::FontFit fit = basalt::textFitScale(
|
|
107
|
+
attributedString,
|
|
108
|
+
paragraphAttributes,
|
|
109
|
+
static_cast<float>(maxWidth),
|
|
110
|
+
static_cast<float>(layoutConstraints.maximumSize.height));
|
|
111
|
+
RnTextLayout *layout = basalt::buildTextLayout(attributedString, paragraphAttributes, fit);
|
|
103
112
|
const CGSize size = [layout sizeForWidth:maxWidth];
|
|
104
113
|
|
|
105
114
|
// Core Text can report a line slightly wider than the width it was given,
|
|
@@ -114,14 +123,56 @@ TextMeasurement TextLayoutManager::measure(const AttributedStringBox &attributed
|
|
|
114
123
|
// Reporting a zero frame for each keeps the count right, which is what
|
|
115
124
|
// ParagraphShadowNode iterates over, but they are not positioned yet --
|
|
116
125
|
// the same gap the GTK side has, for the same reason.
|
|
126
|
+
// Inline views (`<Text><View/></Text>`), one attachment per view and in
|
|
127
|
+
// fragment order, which is how ParagraphShadowNode pairs them back up. A run
|
|
128
|
+
// delegate reserved each box during layout, so these are where the paragraph
|
|
129
|
+
// put them rather than the zeroes this used to report. See backlog/text.md.
|
|
130
|
+
//
|
|
131
|
+
// The index arithmetic is the part to be careful about: fragment strings are
|
|
132
|
+
// UTF-8 and measured in bytes, while NSAttributedString counts UTF-16, so the
|
|
133
|
+
// offset is accumulated in UTF-16 units as each fragment is appended. Using
|
|
134
|
+
// byte offsets here would be right for ASCII and wrong the moment a paragraph
|
|
135
|
+
// contains an emoji before the view.
|
|
117
136
|
TextMeasurement::Attachments attachments;
|
|
137
|
+
NSUInteger utf16At = 0;
|
|
118
138
|
for (const auto &fragment : attributedString.getFragments()) {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
139
|
+
NSString *text = [NSString stringWithUTF8String:fragment.string.c_str()];
|
|
140
|
+
// The transformed text, because that is what was laid out: a
|
|
141
|
+
// `textTransform` that changes the length would otherwise put every
|
|
142
|
+
// attachment after it at the wrong index.
|
|
143
|
+
text = text != nil ? basalt::transformedFragmentText(fragment, text) : nil;
|
|
144
|
+
const NSUInteger start = utf16At;
|
|
145
|
+
utf16At += text == nil ? 0 : text.length;
|
|
146
|
+
|
|
147
|
+
if (!fragment.isAttachment()) {
|
|
148
|
+
continue;
|
|
124
149
|
}
|
|
150
|
+
|
|
151
|
+
const auto &size = fragment.parentShadowView.layoutMetrics.frame.size;
|
|
152
|
+
if (size.width <= 0 || size.height <= 0) {
|
|
153
|
+
// Nothing measured it, so there is no box and no position to invent.
|
|
154
|
+
attachments.push_back(TextMeasurement::Attachment{.frame = {}, .isClipped = false});
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const CGRect box = [layout frameForCharacterIndex:start width:maxWidth];
|
|
159
|
+
if (CGRectIsNull(box)) {
|
|
160
|
+
// Off the end of a truncated paragraph, which is what isClipped is for.
|
|
161
|
+
attachments.push_back(TextMeasurement::Attachment{.frame = {}, .isClipped = true});
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
attachments.push_back(TextMeasurement::Attachment{
|
|
166
|
+
.frame =
|
|
167
|
+
{
|
|
168
|
+
.origin = {.x = static_cast<Float>(box.origin.x),
|
|
169
|
+
.y = static_cast<Float>(box.origin.y)},
|
|
170
|
+
// The size React Native measured, so a rounding difference in
|
|
171
|
+
// the delegate cannot move the view off its own layout's box.
|
|
172
|
+
.size = size,
|
|
173
|
+
},
|
|
174
|
+
.isClipped = false,
|
|
175
|
+
});
|
|
125
176
|
}
|
|
126
177
|
|
|
127
178
|
measured = TextMeasurement{.size = layoutConstraints.clamp(natural), .attachments = attachments};
|