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.
Files changed (42) hide show
  1. package/native/CMakeLists.txt +16 -2
  2. package/native/appkit/AppKitAppWindow.h +24 -0
  3. package/native/appkit/AppKitImageLoader.h +48 -1
  4. package/native/appkit/AppKitImageLoader.mm +228 -10
  5. package/native/appkit/AppKitMenuBar.mm +58 -0
  6. package/native/appkit/AppKitMountingManager.h +34 -0
  7. package/native/appkit/AppKitMountingManager.mm +576 -9
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitTextInput.h +38 -1
  11. package/native/appkit/AppKitTextInput.mm +235 -8
  12. package/native/appkit/AppKitTextPeer.h +60 -0
  13. package/native/appkit/AppKitTextPeer.mm +307 -0
  14. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  15. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  16. package/native/appkit/AppKitWindowControl.mm +38 -3
  17. package/native/appkit/ComponentRegistryAppKit.mm +20 -0
  18. package/native/appkit/CoreTextLayout.h +64 -2
  19. package/native/appkit/CoreTextLayout.mm +431 -71
  20. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  21. package/native/appkit/FontRegistryCoreText.mm +6 -5
  22. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  23. package/native/appkit/RnAppKitView.h +492 -0
  24. package/native/appkit/RnAppKitView.mm +2266 -23
  25. package/native/appkit/RnTextLayout.h +106 -0
  26. package/native/appkit/RnTextLayout.mm +321 -2
  27. package/native/appkit/main_appkit.mm +174 -3
  28. package/native/tests/test_appkit_accessibility.mm +289 -0
  29. package/native/tests/test_appkit_borders.mm +975 -0
  30. package/native/tests/test_appkit_fonts.mm +203 -0
  31. package/native/tests/test_appkit_image.mm +392 -17
  32. package/native/tests/test_appkit_input.mm +209 -0
  33. package/native/tests/test_appkit_mounting.mm +360 -0
  34. package/native/tests/test_appkit_scrollbar.mm +125 -0
  35. package/native/tests/test_appkit_scrollview.mm +371 -0
  36. package/native/tests/test_appkit_selection.mm +349 -0
  37. package/native/tests/test_appkit_services.mm +51 -1
  38. package/native/tests/test_appkit_text.mm +1628 -0
  39. package/native/tests/test_appkit_textinput.mm +819 -0
  40. package/native/tests/test_appkit_titlebar.mm +43 -0
  41. package/native/tests/test_appkit_view.mm +609 -0
  42. 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
- NSTextAlignment toTextAlignment(TextAlignment alignment) {
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 TextAlignment::Center:
74
+ case basalt::PhysicalTextAlignment::Center:
58
75
  return NSTextAlignmentCenter;
59
- case TextAlignment::Right:
60
- #if BASALT_RN_MINOR >= 87
61
- case TextAlignment::End:
62
- #endif
76
+ case basalt::PhysicalTextAlignment::Right:
63
77
  return NSTextAlignmentRight;
64
- case TextAlignment::Justified:
78
+ case basalt::PhysicalTextAlignment::Justified:
65
79
  return NSTextAlignmentJustified;
66
- case TextAlignment::Left:
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 NSTextAlignmentNatural;
83
+ return NSTextAlignmentLeft;
74
84
  }
75
85
 
76
- NSColor *toColor(const facebook::react::SharedColor &color) {
77
- const auto components = facebook::react::colorComponentsFromColor(color);
78
- // sRGB explicitly, for the same reason RnAppKitView builds its background
79
- // that way: a device colour space shifts every colour on a wide-gamut
80
- // display, and text that does not match Linux is the bug this project is
81
- // least able to afford.
82
- return [NSColor colorWithSRGBRed:components.red
83
- green:components.green
84
- blue:components.blue
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
- NSFont *fontFor(const TextAttributes &textAttributes) {
89
- float size = isSet(textAttributes.fontSize) ? static_cast<float>(textAttributes.fontSize)
90
- : kDefaultFontSize;
91
- if (isSet(textAttributes.fontSizeMultiplier) && textAttributes.fontSizeMultiplier > 0) {
92
- size *= static_cast<float>(textAttributes.fontSizeMultiplier);
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
- if (textAttributes.alignment) {
143
- style.alignment = toTextAlignment(*textAttributes.alignment);
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. The line *limit* is applied in RnTextLayout, not here:
157
- // a truncating line break mode would make Core Text ellipsize each line it
158
- // lays out rather than the last one kept.
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
- NSDictionary<NSAttributedStringKey, id> *buildTextAttributes(const TextAttributes &textAttributes) {
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
- if (textAttributes.foregroundColor) {
173
- attributes[NSForegroundColorAttributeName] = toColor(textAttributes.foregroundColor);
174
- } else {
175
- // React Native's default is opaque black, not the system label colour --
176
- // which is white in dark mode and would make default text invisible on a
177
- // light background, or vice versa. Matching React Native rather than the
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.backgroundColor) {
184
- attributes[NSBackgroundColorAttributeName] = toColor(textAttributes.backgroundColor);
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
- if (textAttributes.textDecorationLineType) {
192
- switch (*textAttributes.textDecorationLineType) {
193
- case TextDecorationLineType::Underline:
194
- attributes[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle);
195
- break;
196
- case TextDecorationLineType::Strikethrough:
197
- attributes[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleSingle);
198
- break;
199
- case TextDecorationLineType::UnderlineStrikethrough:
200
- attributes[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle);
201
- attributes[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleSingle);
202
- break;
203
- case TextDecorationLineType::None:
204
- break;
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 &paragraphAttributes,
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 &paragraphAttributes) {
493
+ const ParagraphAttributes &paragraphAttributes,
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
- [string appendAttributedString:[[NSAttributedString alloc]
224
- initWithString:text
225
- attributes:buildTextAttributes(fragment.textAttributes)]];
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
- return [RnTextLayout layoutWithAttributedString:string
247
- maximumNumberOfLines:paragraphAttributes.maximumNumberOfLines
248
- truncationType:truncation
249
- truncates:truncates ? YES : NO];
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
- RnTextLayout *layout = basalt::buildTextLayout(attributedString, paragraphAttributes);
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
- if (fragment.isAttachment()) {
120
- attachments.push_back(TextMeasurement::Attachment{
121
- .frame = {.origin = {.x = 0, .y = 0}, .size = {.width = 0, .height = 0}},
122
- .isClipped = false,
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};