basalt-appkit 0.0.0-rc5 → 0.2.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 (43) 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 +582 -12
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitSkiaPeer.h +15 -0
  11. package/native/appkit/AppKitTextInput.h +38 -1
  12. package/native/appkit/AppKitTextInput.mm +235 -8
  13. package/native/appkit/AppKitTextPeer.h +60 -0
  14. package/native/appkit/AppKitTextPeer.mm +307 -0
  15. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  16. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  17. package/native/appkit/AppKitWindowControl.mm +38 -3
  18. package/native/appkit/ComponentRegistryAppKit.mm +28 -3
  19. package/native/appkit/CoreTextLayout.h +64 -2
  20. package/native/appkit/CoreTextLayout.mm +431 -71
  21. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  22. package/native/appkit/FontRegistryCoreText.mm +6 -5
  23. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  24. package/native/appkit/RnAppKitView.h +492 -0
  25. package/native/appkit/RnAppKitView.mm +2266 -23
  26. package/native/appkit/RnTextLayout.h +106 -0
  27. package/native/appkit/RnTextLayout.mm +321 -2
  28. package/native/appkit/main_appkit.mm +174 -3
  29. package/native/tests/test_appkit_accessibility.mm +289 -0
  30. package/native/tests/test_appkit_borders.mm +975 -0
  31. package/native/tests/test_appkit_fonts.mm +203 -0
  32. package/native/tests/test_appkit_image.mm +392 -17
  33. package/native/tests/test_appkit_input.mm +209 -0
  34. package/native/tests/test_appkit_mounting.mm +360 -0
  35. package/native/tests/test_appkit_scrollbar.mm +125 -0
  36. package/native/tests/test_appkit_scrollview.mm +371 -0
  37. package/native/tests/test_appkit_selection.mm +349 -0
  38. package/native/tests/test_appkit_services.mm +51 -1
  39. package/native/tests/test_appkit_text.mm +1628 -0
  40. package/native/tests/test_appkit_textinput.mm +819 -0
  41. package/native/tests/test_appkit_titlebar.mm +43 -0
  42. package/native/tests/test_appkit_view.mm +609 -0
  43. package/package.json +4 -3
@@ -13,9 +13,17 @@
13
13
 
14
14
  #include "TestHarness.h"
15
15
 
16
+ #import "CoreTextLayout.h"
17
+
18
+ #include "FontScaling.h"
19
+ #import "RnAppKitView.h"
16
20
  #import "RnTextLayout.h"
17
21
 
22
+ #include <algorithm>
23
+ #include <cmath>
24
+ #include <optional>
18
25
  #include <sstream>
26
+ #include <vector>
19
27
 
20
28
  namespace {
21
29
 
@@ -37,6 +45,70 @@ RnTextLayout *layoutFor(NSString *text, CGFloat size, NSInteger lines) {
37
45
  truncates:YES];
38
46
  }
39
47
 
48
+ // The same paragraph under `ellipsizeMode: 'clip'`, which asks for the line
49
+ // limit and no ellipsis. `truncates:NO` is what CoreTextLayout.mm builds for it,
50
+ // and the truncation type it is given is then never consulted.
51
+ RnTextLayout *clippedLayoutFor(NSString *text, CGFloat size, NSInteger lines) {
52
+ return [RnTextLayout layoutWithAttributedString:styled(text, size, NSTextAlignmentNatural)
53
+ maximumNumberOfLines:lines
54
+ truncationType:kCTLineTruncationEnd
55
+ truncates:NO];
56
+ }
57
+
58
+ // A sentence with an inline view in the middle, built the way CoreTextLayout.mm
59
+ // builds one: the U+FFFC character carrying a run delegate that reports the box.
60
+ // Constructed here rather than going through buildTextLayout so the test does not
61
+ // need React Native's AttributedString, matching the rest of this file.
62
+ CGFloat attachAscent(void *context) {
63
+ return static_cast<CGFloat *>(context)[1];
64
+ }
65
+ CGFloat attachDescent(void * /*context*/) {
66
+ return 0;
67
+ }
68
+ CGFloat attachWidth(void *context) {
69
+ return static_cast<CGFloat *>(context)[0];
70
+ }
71
+ void attachDealloc(void *context) {
72
+ delete[] static_cast<CGFloat *>(context);
73
+ }
74
+
75
+ NSAttributedString *withInlineView(CGFloat width, CGFloat height, CGFloat fontSize = 16.0) {
76
+ NSMutableAttributedString *string = [[NSMutableAttributedString alloc] init];
77
+ NSDictionary *attributes = @{NSFontAttributeName : [NSFont systemFontOfSize:fontSize]};
78
+
79
+ [string appendAttributedString:[[NSAttributedString alloc] initWithString:@"before "
80
+ attributes:attributes]];
81
+
82
+ CTRunDelegateCallbacks callbacks = {};
83
+ callbacks.version = kCTRunDelegateCurrentVersion;
84
+ callbacks.dealloc = attachDealloc;
85
+ callbacks.getAscent = attachAscent;
86
+ callbacks.getDescent = attachDescent;
87
+ callbacks.getWidth = attachWidth;
88
+ auto *box = new CGFloat[2]{width, height};
89
+ CTRunDelegateRef delegate = CTRunDelegateCreate(&callbacks, box);
90
+
91
+ NSMutableDictionary *withDelegate = [attributes mutableCopy];
92
+ withDelegate[(__bridge NSString *)kCTRunDelegateAttributeName] = (__bridge_transfer id)delegate;
93
+ // U+FFFC, the object replacement character React Native uses for an attachment.
94
+ [string appendAttributedString:[[NSAttributedString alloc] initWithString:@"\uFFFC"
95
+ attributes:withDelegate]];
96
+
97
+ [string appendAttributedString:[[NSAttributedString alloc] initWithString:@" after"
98
+ attributes:attributes]];
99
+ return string;
100
+ }
101
+
102
+ RnTextLayout *inlineLayout(CGFloat width, CGFloat height) {
103
+ return [RnTextLayout layoutWithAttributedString:withInlineView(width, height)
104
+ maximumNumberOfLines:0
105
+ truncationType:kCTLineTruncationEnd
106
+ truncates:YES];
107
+ }
108
+
109
+ // "before " is 7 characters, so the attachment is at UTF-16 index 7.
110
+ constexpr NSUInteger kAttachmentIndex = 7;
111
+
40
112
  NSString *const kLong =
41
113
  @"A paragraph long enough to wrap several times over, so that the line breaking has real "
42
114
  @"work to do and a line limit has something to cut.";
@@ -109,6 +181,49 @@ TEST(a_line_limit_that_fits_is_not_truncated) {
109
181
  }
110
182
  }
111
183
 
184
+ // `numberOfLines` with `ellipsizeMode: 'clip'`: cut to the limit, with no
185
+ // ellipsis on the last line that stays.
186
+ //
187
+ // The reason to assert this on a host that already does it is that the line
188
+ // limit and the ellipsis are two separate decisions, and a text engine that
189
+ // only truncates in order to place an ellipsis puts them back together. Pango
190
+ // is exactly that engine -- a line limit there is a negative height it consults
191
+ // only while ellipsizing -- so the GTK side had to grow the cut by hand, and
192
+ // these are the tests that say Core Text must never acquire the same coupling.
193
+ // See backlog/text.md.
194
+
195
+ TEST(a_line_limit_applies_without_an_ellipsis_too) {
196
+ @autoreleasepool {
197
+ const CGSize unlimited = [layoutFor(kLong, 16, 0) sizeForWidth:200];
198
+ const CGSize ellipsized = [layoutFor(kLong, 16, 2) sizeForWidth:200];
199
+ const CGSize clipped = [clippedLayoutFor(kLong, 16, 2) sizeForWidth:200];
200
+
201
+ // Shorter than the whole paragraph: the limit is honoured even though
202
+ // nothing is being replaced by an ellipsis.
203
+ EXPECT(clipped.height < unlimited.height);
204
+
205
+ // And the same height as the ellipsizing one, because the two keep the same
206
+ // two lines and differ only in what the second one ends with.
207
+ EXPECT_NEAR(clipped.height, ellipsized.height, 1.0);
208
+ }
209
+ }
210
+
211
+ TEST(clipping_keeps_exactly_the_number_of_lines_it_was_given) {
212
+ @autoreleasepool {
213
+ const CGSize one = [clippedLayoutFor(kLong, 16, 1) sizeForWidth:200];
214
+ const CGSize two = [clippedLayoutFor(kLong, 16, 2) sizeForWidth:200];
215
+ const CGSize three = [clippedLayoutFor(kLong, 16, 3) sizeForWidth:200];
216
+
217
+ // Keeping one line too few or too many would still measure shorter than the
218
+ // whole paragraph, which is what the test above checks, so the count itself
219
+ // is asserted here.
220
+ EXPECT(two.height > one.height);
221
+ EXPECT(three.height > two.height);
222
+ EXPECT_NEAR(two.height, one.height * 2, 2.0);
223
+ EXPECT_NEAR(three.height, one.height * 3, 3.0);
224
+ }
225
+ }
226
+
112
227
  // Alignment changes where a line is drawn, never how big it is. A paragraph
113
228
  // that measured differently when centred would make Yoga lay it out wrong.
114
229
  TEST(alignment_does_not_change_the_measured_size) {
@@ -178,3 +293,1516 @@ TEST(text_draws_where_the_alignment_says) {
178
293
  EXPECT(right > centred);
179
294
  }
180
295
  }
296
+
297
+ // Inline views: `<Text>before <View/> after</Text>`, where a run delegate carries
298
+ // the size React Native measured for the view. The counterpart of the Pango
299
+ // tests on the other host. See backlog/text.md.
300
+
301
+ TEST(an_inline_view_reserves_its_own_width) {
302
+ const CGSize narrow = [inlineLayout(10, 20) sizeForWidth:-1];
303
+ const CGSize wide = [inlineLayout(120, 20) sizeForWidth:-1];
304
+
305
+ EXPECT(wide.width > narrow.width);
306
+ EXPECT(wide.width - narrow.width > 100);
307
+ }
308
+
309
+ TEST(a_tall_inline_view_makes_the_line_taller) {
310
+ const CGSize shortOne = [inlineLayout(20, 8) sizeForWidth:-1];
311
+ const CGSize tall = [inlineLayout(20, 90) sizeForWidth:-1];
312
+
313
+ EXPECT(tall.height > shortOne.height);
314
+ }
315
+
316
+ TEST(an_inline_view_is_positioned_after_the_text_before_it) {
317
+ const CGRect box = [inlineLayout(40, 20) frameForCharacterIndex:kAttachmentIndex width:-1];
318
+
319
+ EXPECT(!CGRectIsNull(box));
320
+ EXPECT(box.origin.x > 0);
321
+ }
322
+
323
+ TEST(an_inline_view_is_as_tall_as_it_asked_to_be) {
324
+ const CGRect box = [inlineLayout(40, 40) frameForCharacterIndex:kAttachmentIndex width:-1];
325
+
326
+ EXPECT(!CGRectIsNull(box));
327
+ // Its own run's bounds, not the line's, which is why a view taller than the
328
+ // text reports its own height rather than the text's.
329
+ EXPECT(box.size.height >= 39);
330
+ EXPECT(box.size.height <= 41);
331
+ }
332
+
333
+ TEST(a_wrapped_inline_view_sits_lower) {
334
+ const CGRect wide = [inlineLayout(40, 20) frameForCharacterIndex:kAttachmentIndex width:-1];
335
+ const CGRect wrapped = [inlineLayout(40, 20) frameForCharacterIndex:kAttachmentIndex width:60];
336
+
337
+ EXPECT(!CGRectIsNull(wide));
338
+ EXPECT(!CGRectIsNull(wrapped));
339
+ EXPECT(wrapped.origin.y > wide.origin.y);
340
+ }
341
+
342
+ TEST(a_character_past_the_end_has_no_frame) {
343
+ const CGRect box = [inlineLayout(40, 20) frameForCharacterIndex:9999 width:-1];
344
+ EXPECT(CGRectIsNull(box));
345
+ }
346
+
347
+ TEST(an_inline_view_below_a_clip_has_no_frame) {
348
+ @autoreleasepool {
349
+ // At width 60 "before " fills the first line and the box is pushed onto the
350
+ // second, which is what `a_wrapped_inline_view_sits_lower` above relies on.
351
+ // A one-line limit then hides that second line, so there is no position to
352
+ // report and CoreTextLayoutManager turns the null rect into `isClipped`.
353
+ // That is the one place a line limit without an ellipsis is visible to
354
+ // React Native rather than only to the eye.
355
+ RnTextLayout *clipped = [RnTextLayout layoutWithAttributedString:withInlineView(40, 20)
356
+ maximumNumberOfLines:1
357
+ truncationType:kCTLineTruncationEnd
358
+ truncates:NO];
359
+
360
+ const CGRect hidden = [clipped frameForCharacterIndex:kAttachmentIndex width:60];
361
+ EXPECT(CGRectIsNull(hidden));
362
+
363
+ // The same paragraph with room for both lines does place it, so the null
364
+ // above is the limit at work and not the query failing.
365
+ const CGRect placed = [inlineLayout(40, 20) frameForCharacterIndex:kAttachmentIndex width:60];
366
+ EXPECT(!CGRectIsNull(placed));
367
+ }
368
+ }
369
+
370
+ // `textAlign: 'justify'`, which works and was recorded as not working.
371
+ //
372
+ // backlog/platform-macos.md said Core Text ignores justification for lines drawn
373
+ // one at a time, which is true and is not what this draws: `linesForWidth:`
374
+ // takes its lines out of a `CTFrame`, and Core Text justifies the lines in a
375
+ // frame itself. So the prop has been honoured all along and nothing asserted it.
376
+ //
377
+ // Measured as the rightmost column with ink in it: a justified line reaches the
378
+ // right edge of the box, and the same text drawn flush left stops short of it --
379
+ // 199 against 186 in a 200 point box, which is also the evidence that struck
380
+ // that entry.
381
+ TEST(text_justified_lines_reach_both_edges) {
382
+ @autoreleasepool {
383
+ const CGSize size = CGSizeMake(200, 80);
384
+ // Long enough to wrap to three lines in 200 points, with spaces to stretch.
385
+ NSString *paragraph = @"Justified text stretches every line but the last one";
386
+
387
+ const auto inkEdges = [&](NSTextAlignment alignment, int row) {
388
+ RnTextLayout *layout =
389
+ [RnTextLayout layoutWithAttributedString:styled(paragraph, 14, alignment)
390
+ maximumNumberOfLines:0
391
+ truncationType:kCTLineTruncationEnd
392
+ truncates:YES];
393
+
394
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
395
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
396
+ 8, 0, space, kCGImageAlphaPremultipliedLast);
397
+ CGColorSpaceRelease(space);
398
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
399
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
400
+ [layout drawInContext:context size:size];
401
+
402
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
403
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
404
+ // The bitmap counts y from the bottom and the layout draws from the top, so
405
+ // the first drawn line is the last band of rows.
406
+ const size_t band = (size_t)size.height - (size_t)row * 20 - 10;
407
+ int rightmost = -1;
408
+ for (size_t x = 0; x < (size_t)size.width; x++) {
409
+ for (size_t y = band - 8; y < band + 8; y++) {
410
+ if (pixels[y * stride + x * 4] < 200) {
411
+ rightmost = (int)x;
412
+ break;
413
+ }
414
+ }
415
+ }
416
+ CGContextRelease(context);
417
+ return rightmost;
418
+ };
419
+
420
+ // The first line, which is justified.
421
+ const int flushLeft = inkEdges(NSTextAlignmentNatural, 0);
422
+ const int justified = inkEdges(NSTextAlignmentJustified, 0);
423
+ EXPECT(flushLeft > 0);
424
+ // Stretched to the edge: within a couple of points of 200, and further right
425
+ // than the same line left-aligned.
426
+ EXPECT(justified > flushLeft);
427
+ EXPECT(justified >= 195);
428
+ }
429
+ }
430
+
431
+ // And the last line is left alone, which is what justification means
432
+ // typographically and what Pango does on the other host -- Core Text agrees
433
+ // without being asked, which is the other half of this being already done.
434
+ //
435
+ // The lines are found rather than assumed: the paragraph's own wrapping decides
436
+ // how many there are, so this collects the rightmost ink per row of the bitmap,
437
+ // groups the rows that have any into bands, and asks about the first band and
438
+ // the last.
439
+ TEST(text_the_last_justified_line_is_not_stretched) {
440
+ @autoreleasepool {
441
+ const CGSize size = CGSizeMake(200, 80);
442
+ NSString *paragraph = @"Justified text stretches every line but the last one";
443
+
444
+ RnTextLayout *layout =
445
+ [RnTextLayout layoutWithAttributedString:styled(paragraph, 14, NSTextAlignmentJustified)
446
+ maximumNumberOfLines:0
447
+ truncationType:kCTLineTruncationEnd
448
+ truncates:YES];
449
+
450
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
451
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
452
+ 8, 0, space, kCGImageAlphaPremultipliedLast);
453
+ CGColorSpaceRelease(space);
454
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
455
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
456
+ [layout drawInContext:context size:size];
457
+
458
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
459
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
460
+
461
+ // The rightmost inked column in each row, or -1 for a blank row. The bitmap
462
+ // counts y from the bottom, so the first drawn line is at the high end.
463
+ std::vector<int> rightmostByRow((size_t)size.height, -1);
464
+ for (size_t y = 0; y < (size_t)size.height; y++) {
465
+ for (size_t x = (size_t)size.width; x > 0; x--) {
466
+ if (pixels[y * stride + (x - 1) * 4] < 200) {
467
+ rightmostByRow[y] = (int)(x - 1);
468
+ break;
469
+ }
470
+ }
471
+ }
472
+ CGContextRelease(context);
473
+
474
+ // Group the inked rows into bands, one per drawn line, and take each band's
475
+ // furthest ink.
476
+ std::vector<int> bands;
477
+ bool inBand = false;
478
+ int furthest = -1;
479
+ for (size_t y = (size_t)size.height; y > 0; y--) {
480
+ const int rightmost = rightmostByRow[y - 1];
481
+ if (rightmost >= 0) {
482
+ inBand = true;
483
+ furthest = std::max(furthest, rightmost);
484
+ } else if (inBand) {
485
+ bands.push_back(furthest);
486
+ inBand = false;
487
+ furthest = -1;
488
+ }
489
+ }
490
+ if (inBand) {
491
+ bands.push_back(furthest);
492
+ }
493
+
494
+ // Three lines in this box, and in any case more than one: a single line
495
+ // would make the question meaningless, the first line being the last.
496
+ EXPECT(bands.size() >= 2);
497
+ // The first is stretched to the edge and the last is not. A host that
498
+ // justified every line would push the last one to 200 as well, and the
499
+ // paragraph would read as a bug rather than as a choice.
500
+ EXPECT(bands.front() >= 195);
501
+ EXPECT(bands.back() < 190);
502
+ }
503
+ }
504
+
505
+ // `textTransform`, the same four questions the GTK suite asks, over the same
506
+ // cases: the two hosts use their own toolkit's Unicode case mapping -- NSString's
507
+ // here, GLib's there -- and the point of asking twice is that they agree.
508
+ namespace {
509
+
510
+ std::string transformedText(const std::string &text, facebook::react::TextTransform transform) {
511
+ facebook::react::TextAttributes attributes;
512
+ attributes.fontSize = 16.0F;
513
+ attributes.textTransform = transform;
514
+
515
+ facebook::react::AttributedString::Fragment fragment;
516
+ fragment.string = text;
517
+ fragment.textAttributes = attributes;
518
+
519
+ facebook::react::AttributedString string;
520
+ string.appendFragment(std::move(fragment));
521
+
522
+ RnTextLayout *layout = basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{});
523
+ NSString *laid = layout.attributedString.string;
524
+ return laid != nil ? std::string(laid.UTF8String) : std::string();
525
+ }
526
+
527
+ } // namespace
528
+
529
+ TEST(appkit_text_transform_uppercases_and_lowercases) {
530
+ @autoreleasepool {
531
+ using facebook::react::TextTransform;
532
+ EXPECT_EQ(transformedText("shout", TextTransform::Uppercase), std::string("SHOUT"));
533
+ EXPECT_EQ(transformedText("WHISPER", TextTransform::Lowercase), std::string("whisper"));
534
+ EXPECT_EQ(transformedText("As Written", TextTransform::None), std::string("As Written"));
535
+ }
536
+ }
537
+
538
+ TEST(appkit_text_transform_knows_unicode_rather_than_bytes) {
539
+ @autoreleasepool {
540
+ using facebook::react::TextTransform;
541
+ EXPECT_EQ(transformedText("café", TextTransform::Uppercase), std::string("CAFÉ"));
542
+ // One character in, two out, which a byte-wise transform cannot do.
543
+ EXPECT_EQ(transformedText("straße", TextTransform::Uppercase), std::string("STRASSE"));
544
+ }
545
+ }
546
+
547
+ TEST(appkit_text_transform_capitalize_follows_react_natives_rule) {
548
+ @autoreleasepool {
549
+ using facebook::react::TextTransform;
550
+ EXPECT_EQ(transformedText("hello wide world", TextTransform::Capitalize),
551
+ std::string("Hello Wide World"));
552
+ EXPECT_EQ(transformedText("iOS and Android", TextTransform::Capitalize),
553
+ std::string("Ios And Android"));
554
+ EXPECT_EQ(transformedText("3rd place", TextTransform::Capitalize), std::string("3rd Place"));
555
+ }
556
+ }
557
+
558
+ // And it reaches the measurement, not only the drawing: "shout" and "SHOUT" are
559
+ // different widths, so a host that transformed at paint time would wrap in the
560
+ // wrong place.
561
+ TEST(appkit_text_transform_changes_what_the_paragraph_measures) {
562
+ @autoreleasepool {
563
+ using facebook::react::TextTransform;
564
+ const auto widthOf = [](facebook::react::TextTransform transform) {
565
+ facebook::react::TextAttributes attributes;
566
+ attributes.fontSize = 16.0F;
567
+ attributes.textTransform = transform;
568
+ facebook::react::AttributedString::Fragment fragment;
569
+ fragment.string = "shout";
570
+ fragment.textAttributes = attributes;
571
+ facebook::react::AttributedString string;
572
+ string.appendFragment(std::move(fragment));
573
+
574
+ RnTextLayout *layout =
575
+ basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{});
576
+ return [layout sizeForWidth:-1].width;
577
+ };
578
+
579
+ EXPECT(widthOf(TextTransform::Uppercase) > widthOf(TextTransform::None));
580
+ }
581
+ }
582
+
583
+ // `textShadowColor`, `textShadowOffset` and `textShadowRadius`, against a real
584
+ // bitmap.
585
+ //
586
+ // Which fragment's shadow wins is core's and is tested there. What only a
587
+ // picture can say is that the shadow is drawn at all -- Core Text ignores an
588
+ // `NSShadow` attribute, so this one is set on the context -- and that its offset
589
+ // goes the way React Native means: the drawing flips the context back to Core
590
+ // Text's y-up orientation, so a positive offset has to be negated on the way in
591
+ // or the shadow lands above the glyphs instead of below them.
592
+ TEST(text_a_shadow_is_drawn_where_react_native_offsets_it) {
593
+ @autoreleasepool {
594
+ const CGSize size = CGSizeMake(200, 100);
595
+
596
+ // The red channel of every pixel, so a red shadow and black glyphs on white
597
+ // are told apart by their green instead.
598
+ const auto draw = [&](CGFloat dx, CGFloat dy, NSColor *shadowColor) {
599
+ RnTextLayout *layout =
600
+ [RnTextLayout layoutWithAttributedString:styled(@"H", 48, NSTextAlignmentLeft)
601
+ maximumNumberOfLines:0
602
+ truncationType:kCTLineTruncationEnd
603
+ truncates:YES];
604
+ layout.shadowOffset = CGSizeMake(dx, dy);
605
+ layout.shadowStandardDeviation = 0;
606
+ layout.shadowColor = shadowColor;
607
+
608
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
609
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
610
+ 8, 0, space, kCGImageAlphaPremultipliedLast);
611
+ CGColorSpaceRelease(space);
612
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
613
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
614
+ [layout drawInContext:context size:size];
615
+ return context;
616
+ };
617
+
618
+ // Where the ink is without a shadow, measured rather than assumed: the font
619
+ // is whatever this machine has.
620
+ CGContextRef plain = draw(0, 0, nil);
621
+ auto *plainPixels = static_cast<unsigned char *>(CGBitmapContextGetData(plain));
622
+ const size_t stride = CGBitmapContextGetBytesPerRow(plain);
623
+ int left = (int)size.width;
624
+ int right = -1;
625
+ int top = (int)size.height;
626
+ int bottom = -1;
627
+ for (int x = 0; x < (int)size.width; x++) {
628
+ for (int y = 0; y < (int)size.height; y++) {
629
+ if (plainPixels[y * stride + x * 4] < 200) {
630
+ left = std::min(left, x);
631
+ right = std::max(right, x);
632
+ top = std::min(top, y);
633
+ bottom = std::max(bottom, y);
634
+ }
635
+ }
636
+ }
637
+ CGContextRelease(plain);
638
+ EXPECT(right > left);
639
+ EXPECT(bottom > top);
640
+
641
+ // A hard red shadow twenty points right and ten down. The part past the
642
+ // glyph's own right edge is where nothing was drawn before, and the rows
643
+ // *below* the glyph are what say the sign survived the flip.
644
+ NSColor *red = [NSColor colorWithSRGBRed:1 green:0 blue:0 alpha:1];
645
+ CGContextRef shadowed = draw(20, 10, red);
646
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(shadowed));
647
+
648
+ const auto redAt = [&](int x, int y) {
649
+ const unsigned char *pixel = &pixels[(size_t)y * stride + (size_t)x * 4];
650
+ return pixel[0] > 150 && pixel[1] < 120 && pixel[2] < 120;
651
+ };
652
+
653
+ int redPastTheGlyph = 0;
654
+ int redBelowTheGlyph = 0;
655
+ int redAboveTheGlyph = 0;
656
+ for (int x = right + 1; x <= right + 19 && x < (int)size.width; x++) {
657
+ for (int y = top; y <= bottom; y++) {
658
+ if (redAt(x, y)) {
659
+ redPastTheGlyph++;
660
+ }
661
+ }
662
+ }
663
+ for (int x = left; x <= right; x++) {
664
+ for (int y = bottom + 1; y <= bottom + 9 && y < (int)size.height; y++) {
665
+ if (redAt(x, y)) {
666
+ redBelowTheGlyph++;
667
+ }
668
+ }
669
+ for (int y = std::max(top - 9, 0); y < top; y++) {
670
+ if (redAt(x, y)) {
671
+ redAboveTheGlyph++;
672
+ }
673
+ }
674
+ }
675
+ CGContextRelease(shadowed);
676
+
677
+ EXPECT(redPastTheGlyph > 0);
678
+ // Down the screen, which is what a positive offset means to React Native.
679
+ EXPECT(redBelowTheGlyph > 0);
680
+ // And not up it, which is what the unflipped sign would have done.
681
+ EXPECT_EQ(redAboveTheGlyph, 0);
682
+ }
683
+ }
684
+
685
+ // No colour is no shadow, and taking one away takes the shadow with it: a
686
+ // context shadow left set would shadow everything drawn after the text.
687
+ TEST(text_no_shadow_colour_is_no_shadow) {
688
+ @autoreleasepool {
689
+ const CGSize size = CGSizeMake(200, 100);
690
+ RnTextLayout *layout =
691
+ [RnTextLayout layoutWithAttributedString:styled(@"H", 48, NSTextAlignmentLeft)
692
+ maximumNumberOfLines:0
693
+ truncationType:kCTLineTruncationEnd
694
+ truncates:YES];
695
+ layout.shadowOffset = CGSizeMake(20, 10);
696
+ layout.shadowStandardDeviation = 4;
697
+ // No colour, which is React Native's default for every <Text>.
698
+ EXPECT(layout.shadowColor == nil);
699
+
700
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
701
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
702
+ 8, 0, space, kCGImageAlphaPremultipliedLast);
703
+ CGColorSpaceRelease(space);
704
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
705
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
706
+ [layout drawInContext:context size:size];
707
+
708
+ // A rectangle drawn after the paragraph, which would pick up a context
709
+ // shadow that the drawing had left behind.
710
+ CGContextSetRGBFillColor(context, 0, 0, 1, 1);
711
+ CGContextFillRect(context, CGRectMake(120, 60, 20, 20));
712
+
713
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
714
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
715
+ // Just outside the blue rectangle: white, not a shadow of it.
716
+ const unsigned char *beside = &pixels[(size_t)70 * stride + (size_t)145 * 4];
717
+ EXPECT(beside[0] > 240);
718
+ EXPECT(beside[1] > 240);
719
+ EXPECT(beside[2] > 240);
720
+ CGContextRelease(context);
721
+ }
722
+ }
723
+
724
+ // The text scale, and the two props that decide whether a fragment honours it.
725
+ //
726
+ // The same question the GTK suite asks, spelled the same way, because the rule
727
+ // is core's and the only thing that differs is which font the scale reaches.
728
+ // macOS reports no scale of its own -- appKitTextScale() says why -- so what is
729
+ // asserted here is that the host passes whatever it is given through to the
730
+ // font, which is what makes the two props mean anything on this platform.
731
+ namespace {
732
+
733
+ CGFloat pointSizeAt(float scale, std::optional<bool> allowFontScaling,
734
+ double maxFontSizeMultiplier) {
735
+ basalt::setSystemFontScale(scale);
736
+
737
+ facebook::react::TextAttributes attributes;
738
+ attributes.fontSize = 16.0F;
739
+ attributes.allowFontScaling = allowFontScaling;
740
+ attributes.maxFontSizeMultiplier = maxFontSizeMultiplier;
741
+
742
+ facebook::react::AttributedString::Fragment fragment;
743
+ fragment.string = "scaled";
744
+ fragment.textAttributes = attributes;
745
+
746
+ facebook::react::AttributedString string;
747
+ string.appendFragment(std::move(fragment));
748
+
749
+ RnTextLayout *layout = basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{});
750
+ NSFont *font = [layout.attributedString attribute:NSFontAttributeName
751
+ atIndex:0
752
+ effectiveRange:nullptr];
753
+ basalt::setSystemFontScale(1.0F);
754
+ return font != nil ? font.pointSize : 0.0;
755
+ }
756
+
757
+ } // namespace
758
+
759
+ TEST(appkit_a_text_scale_reaches_the_font) {
760
+ @autoreleasepool {
761
+ EXPECT(std::fabs(pointSizeAt(1.0F, std::nullopt, std::nan("")) - 16.0) < 0.01);
762
+ EXPECT(std::fabs(pointSizeAt(1.5F, std::nullopt, std::nan("")) - 24.0) < 0.01);
763
+ }
764
+ }
765
+
766
+ TEST(appkit_allow_font_scaling_false_keeps_the_size) {
767
+ @autoreleasepool {
768
+ EXPECT(std::fabs(pointSizeAt(1.5F, false, std::nan("")) - 16.0) < 0.01);
769
+ EXPECT(std::fabs(pointSizeAt(1.5F, true, std::nan("")) - 24.0) < 0.01);
770
+ }
771
+ }
772
+
773
+ TEST(appkit_max_font_size_multiplier_is_a_ceiling) {
774
+ @autoreleasepool {
775
+ EXPECT(std::fabs(pointSizeAt(2.0F, std::nullopt, 1.25) - 20.0) < 0.01);
776
+ // And it is upstream's rule that a ceiling under 1 is no ceiling at all.
777
+ EXPECT(std::fabs(pointSizeAt(2.0F, std::nullopt, 0.5) - 32.0) < 0.01);
778
+ }
779
+ }
780
+
781
+ TEST(appkit_text_scale_is_one_because_macos_publishes_none) {
782
+ EXPECT(std::fabs(basalt::appKitTextScale() - 1.0F) < 0.001F);
783
+ }
784
+
785
+ // `textDecorationColor` and `textDecorationStyle`, the pair the GTK suite asks
786
+ // about in its own terms. Both read through `buildTextLayout`, so what is
787
+ // asserted is what Core Text was handed, and then that it drew it.
788
+ namespace {
789
+
790
+ NSAttributedString *decorated(
791
+ std::optional<facebook::react::TextDecorationStyle> style,
792
+ bool coloured,
793
+ facebook::react::TextDecorationLineType line =
794
+ facebook::react::TextDecorationLineType::Underline) {
795
+ facebook::react::TextAttributes attributes;
796
+ attributes.fontSize = 24.0F;
797
+ attributes.foregroundColor = facebook::react::colorFromComponents(
798
+ facebook::react::ColorComponents{0.0F, 0.0F, 0.0F, 1.0F});
799
+ attributes.textDecorationLineType = line;
800
+ attributes.textDecorationStyle = style;
801
+ if (coloured) {
802
+ attributes.textDecorationColor = facebook::react::colorFromComponents(
803
+ facebook::react::ColorComponents{1.0F, 0.0F, 0.0F, 1.0F});
804
+ }
805
+
806
+ facebook::react::AttributedString::Fragment fragment;
807
+ fragment.string = "Hxy";
808
+ fragment.textAttributes = attributes;
809
+ facebook::react::AttributedString string;
810
+ string.appendFragment(std::move(fragment));
811
+
812
+ return basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{}).attributedString;
813
+ }
814
+
815
+ } // namespace
816
+
817
+ TEST(appkit_a_decoration_style_maps_to_core_texts_bitmask) {
818
+ @autoreleasepool {
819
+ using facebook::react::TextDecorationStyle;
820
+ const auto underline = [](std::optional<TextDecorationStyle> style) {
821
+ NSNumber *value = [decorated(style, false) attribute:NSUnderlineStyleAttributeName
822
+ atIndex:0
823
+ effectiveRange:nullptr];
824
+ return value == nil ? NSInteger(-1) : value.integerValue;
825
+ };
826
+
827
+ EXPECT_EQ(underline(std::nullopt), NSInteger(NSUnderlineStyleSingle));
828
+ EXPECT_EQ(underline(TextDecorationStyle::Solid), NSInteger(NSUnderlineStyleSingle));
829
+ EXPECT_EQ(underline(TextDecorationStyle::Double), NSInteger(NSUnderlineStyleDouble));
830
+ // The pattern rides in the same bitmask, which is what AppKit has and Pango
831
+ // does not.
832
+ EXPECT_EQ(underline(TextDecorationStyle::Dotted),
833
+ NSInteger(NSUnderlineStyleSingle | NSUnderlineStylePatternDot));
834
+ EXPECT_EQ(underline(TextDecorationStyle::Dashed),
835
+ NSInteger(NSUnderlineStyleSingle | NSUnderlineStylePatternDash));
836
+ #if BASALT_RN_MINOR >= 87
837
+ // And the one Core Text has no pattern for: this host strokes the squiggle
838
+ // itself, so it asks for no underline at all -- an attribute here would
839
+ // draw a straight line through the wave. The colour travels on
840
+ // `RnWavyUnderlineAttributeName` instead, and the next test measures the
841
+ // shape in pixels.
842
+ // -1 is this helper's "no attribute at all", which is the answer.
843
+ //
844
+ // `wavy` is 0.87's; 0.86's enum stops at Dashed.
845
+ EXPECT_EQ(underline(TextDecorationStyle::Wavy), NSInteger(-1));
846
+ #endif
847
+ }
848
+ }
849
+
850
+ TEST(appkit_a_strikethrough_takes_the_same_style_and_colour) {
851
+ @autoreleasepool {
852
+ NSAttributedString *string =
853
+ decorated(facebook::react::TextDecorationStyle::Dotted, true,
854
+ facebook::react::TextDecorationLineType::UnderlineStrikethrough);
855
+ NSNumber *strike = [string attribute:NSStrikethroughStyleAttributeName
856
+ atIndex:0
857
+ effectiveRange:nullptr];
858
+ EXPECT(strike != nil);
859
+ EXPECT(strike != nil
860
+ && strike.integerValue == NSInteger(NSUnderlineStyleSingle | NSUnderlineStylePatternDot));
861
+ EXPECT([string attribute:NSStrikethroughColorAttributeName
862
+ atIndex:0
863
+ effectiveRange:nullptr] != nil);
864
+ }
865
+ }
866
+
867
+ // An unset colour is the text's own, which means *no* attribute: setting one
868
+ // would be this platform choosing a colour React Native did not.
869
+ TEST(appkit_an_unset_decoration_colour_sets_no_attribute) {
870
+ @autoreleasepool {
871
+ EXPECT([decorated(std::nullopt, false) attribute:NSUnderlineColorAttributeName
872
+ atIndex:0
873
+ effectiveRange:nullptr] == nil);
874
+ EXPECT([decorated(std::nullopt, true) attribute:NSUnderlineColorAttributeName
875
+ atIndex:0
876
+ effectiveRange:nullptr] != nil);
877
+ }
878
+ }
879
+
880
+ // And it reaches the page. Black text, a red underline, and a red pixel is one
881
+ // the glyphs cannot have drawn.
882
+ TEST(appkit_a_decoration_colour_reaches_the_underline) {
883
+ @autoreleasepool {
884
+ const CGSize size = CGSizeMake(200, 60);
885
+ const auto redPixels = [&](bool coloured) {
886
+ RnTextLayout *layout = [RnTextLayout
887
+ layoutWithAttributedString:decorated(std::nullopt, coloured)
888
+ maximumNumberOfLines:0
889
+ truncationType:kCTLineTruncationEnd
890
+ truncates:YES];
891
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
892
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width,
893
+ (size_t)size.height, 8, 0, space,
894
+ kCGImageAlphaPremultipliedLast);
895
+ CGColorSpaceRelease(space);
896
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
897
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
898
+ [layout drawInContext:context size:size];
899
+
900
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
901
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
902
+ int red = 0;
903
+ for (size_t y = 0; y < (size_t)size.height; y++) {
904
+ for (size_t x = 0; x < (size_t)size.width; x++) {
905
+ const unsigned char *pixel = pixels + y * stride + x * 4;
906
+ if (pixel[0] > 150 && pixel[1] < 110 && pixel[2] < 110) {
907
+ red++;
908
+ }
909
+ }
910
+ }
911
+ CGContextRelease(context);
912
+ return red;
913
+ };
914
+
915
+ // The negative control: black text with an uncoloured underline puts no red
916
+ // on the page at all.
917
+ EXPECT_EQ(redPixels(false), 0);
918
+ EXPECT(redPixels(true) > 0);
919
+ }
920
+ }
921
+
922
+ // The style this host has no value for, in the one term that can tell it from
923
+ // the fallback: its shape.
924
+ //
925
+ // `appkit_a_decoration_style_maps_to_core_texts_bitmask` records that `wavy`
926
+ // becomes NSUnderlineStyleSingle, which is honest about the translation and
927
+ // says nothing about the result -- a reader of the support page is told
928
+ // `partial` and shown a straight line under text that asked for a squiggle. The
929
+ // Win32 host draws all five, and this is its test: a wave is the one underline
930
+ // whose lowest edge moves, so for every column carrying ink the bottom-most
931
+ // inked row is taken and what is asserted is the spread between them. A
932
+ // straight line gives the same row for every column however the font or the
933
+ // screen scales, which is what makes the claim portable.
934
+ //
935
+ // What would close it: `CTFontGetUnderlinePosition` and
936
+ // `CTFontGetUnderlineThickness` give the two numbers Core Text would have drawn
937
+ // the line from, and `drawInContext:size:` already has the context in Core
938
+ // Text's own orientation, so the squiggle is a path stroked there with the
939
+ // underline attribute left off that range. backlog/text.md records the same
940
+ // plan, an `NSLayoutManager` subclass being the other way round.
941
+ namespace {
942
+
943
+ #if BASALT_RN_MINOR >= 87
944
+ // Narrow glyphs with no descender, so the lowest ink in a column belongs to the
945
+ // underline rather than to a letter, at a size where the line is thick enough
946
+ // for a wave in it to be several pixels from crest to trough.
947
+ //
948
+ // All of this, down to the `#endif` below, is the wavy underline's: `wavy`
949
+ // arrived in React Native 0.87 and naming the member on 0.86 does not
950
+ // compile, so the test is guarded -- and an unused helper or constant left
951
+ // outside the guard is an error in its own right here.
952
+ constexpr float kDecorationFontSize = 40.0F;
953
+ const char *const kNarrowGlyphs = "iiiiiiiiiiiiiiiiiiii";
954
+
955
+ // How far the underline's lowest edge moves across the columns it covers, in
956
+ // pixels, or -1 for a picture with no line in it at all.
957
+ //
958
+ // Measured as the *difference* between two renders of the same text, one
959
+ // decorated and one not, which is what isolates the line from the letters. The
960
+ // first version of this read the lowest dark pixel in each column and the
961
+ // control failed: at the two ends of the line the stroke is antialiased below
962
+ // the ink threshold, so those columns reported the bottom of a glyph instead
963
+ // and a solid underline looked 33 rows out of level. A difference has no such
964
+ // edge case -- a column where the two renders agree holds no line at all and
965
+ // is skipped.
966
+ int underlineBottomEdgeSpread(facebook::react::TextDecorationStyle style) {
967
+ const CGSize size = CGSizeMake(600, 120);
968
+
969
+ auto render = [&](bool decorated) {
970
+ facebook::react::TextAttributes attributes;
971
+ attributes.fontSize = kDecorationFontSize;
972
+ attributes.foregroundColor = facebook::react::colorFromComponents(
973
+ facebook::react::ColorComponents{0.0F, 0.0F, 0.0F, 1.0F});
974
+ if (decorated) {
975
+ attributes.textDecorationLineType = facebook::react::TextDecorationLineType::Underline;
976
+ attributes.textDecorationStyle = style;
977
+ }
978
+
979
+ facebook::react::AttributedString::Fragment fragment;
980
+ fragment.string = kNarrowGlyphs;
981
+ fragment.textAttributes = attributes;
982
+ facebook::react::AttributedString string;
983
+ string.appendFragment(std::move(fragment));
984
+
985
+ RnTextLayout *layout =
986
+ basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{});
987
+
988
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
989
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
990
+ 8, 0, space, kCGImageAlphaPremultipliedLast);
991
+ CGColorSpaceRelease(space);
992
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
993
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
994
+ [layout drawInContext:context size:size];
995
+ return context;
996
+ };
997
+
998
+ CGContextRef decorated = render(true);
999
+ CGContextRef plain = render(false);
1000
+ auto *withLine = static_cast<unsigned char *>(CGBitmapContextGetData(decorated));
1001
+ auto *without = static_cast<unsigned char *>(CGBitmapContextGetData(plain));
1002
+ const size_t stride = CGBitmapContextGetBytesPerRow(decorated);
1003
+
1004
+ int highest = (int)size.height;
1005
+ int lowest = -1;
1006
+ for (int x = 0; x < (int)size.width; x++) {
1007
+ int bottom = -1;
1008
+ for (int y = 0; y < (int)size.height; y++) {
1009
+ const size_t at = (size_t)y * stride + (size_t)x * 4;
1010
+ // A third of full coverage, which is well above the antialiasing either
1011
+ // side of the stroke and well below the stroke itself.
1012
+ if (std::abs((int)withLine[at] - (int)without[at]) > 80) {
1013
+ bottom = y;
1014
+ }
1015
+ }
1016
+ if (bottom < 0) {
1017
+ continue;
1018
+ }
1019
+ highest = std::min(highest, bottom);
1020
+ lowest = std::max(lowest, bottom);
1021
+ }
1022
+ CGContextRelease(decorated);
1023
+ CGContextRelease(plain);
1024
+ return lowest < 0 ? -1 : lowest - highest;
1025
+ }
1026
+ // Guarded with the test that calls it: `wavy` is 0.87's, so on 0.86 this
1027
+ // would be an unused function, which is an error here.
1028
+ #endif
1029
+
1030
+ } // namespace
1031
+
1032
+ #if BASALT_RN_MINOR >= 87
1033
+ TEST(appkit_a_wavy_underline_is_not_straight) {
1034
+ @autoreleasepool {
1035
+ using facebook::react::TextDecorationStyle;
1036
+
1037
+ const int straight = underlineBottomEdgeSpread(TextDecorationStyle::Solid);
1038
+ const int wave = underlineBottomEdgeSpread(TextDecorationStyle::Wavy);
1039
+
1040
+ // Ink at all, first: a paragraph that drew nothing would answer -1 and the
1041
+ // comparison below would be measuring an empty page.
1042
+ EXPECT(straight >= 0);
1043
+ // A solid underline's bottom edge is flat, to within the antialiasing of
1044
+ // one row, which is what the row count is allowed to be and what makes the
1045
+ // instrument worth trusting.
1046
+ EXPECT(straight <= 1);
1047
+ // The wave's rises and falls by its own amplitude, which is the line's
1048
+ // thickness either side of where the straight one would have been. Today
1049
+ // both styles come out of the same NSUnderlineStyleSingle and this is the
1050
+ // assertion that says so.
1051
+ EXPECT(wave >= 3);
1052
+ }
1053
+ }
1054
+ #endif
1055
+
1056
+ // `fontVariant`, in Core Text's vocabulary rather than OpenType's.
1057
+ //
1058
+ // What is asserted is the feature settings on the font, not a picture: whether
1059
+ // `smcp` *changes* anything depends on the font having a small-caps table, and
1060
+ // the font here is whatever the machine has. The AAT pair is this platform's
1061
+ // half of the job and is where a wrong number would hide.
1062
+ namespace {
1063
+
1064
+ // The mapping, not what a font kept of it: `[NSFont fontWithDescriptor:]`
1065
+ // drops features the resolved font does not have, and the system font has
1066
+ // neither oldstyle figures nor twenty stylistic sets. Asking the font would be
1067
+ // asking this machine's font catalogue.
1068
+ NSArray *featuresOf(std::optional<facebook::react::FontVariant> variant) {
1069
+ facebook::react::TextAttributes attributes;
1070
+ attributes.fontSize = 20.0F;
1071
+ attributes.fontVariant = variant;
1072
+ return basalt::fontFeaturesFor(attributes);
1073
+ }
1074
+
1075
+ // And the half that only a real font can answer: a feature the system font does
1076
+ // have has to survive onto the font the layout uses.
1077
+ NSFont *fontWith(facebook::react::FontVariant variant) {
1078
+ facebook::react::TextAttributes attributes;
1079
+ attributes.fontSize = 20.0F;
1080
+ attributes.fontVariant = variant;
1081
+
1082
+ facebook::react::AttributedString::Fragment fragment;
1083
+ fragment.string = "Figures 123";
1084
+ fragment.textAttributes = attributes;
1085
+ facebook::react::AttributedString string;
1086
+ string.appendFragment(std::move(fragment));
1087
+
1088
+ return [basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{})
1089
+ .attributedString attribute:NSFontAttributeName
1090
+ atIndex:0
1091
+ effectiveRange:nullptr];
1092
+ }
1093
+
1094
+ bool hasFeature(NSArray *features, int type, int selector) {
1095
+ for (NSDictionary *feature in features) {
1096
+ if ([feature[NSFontFeatureTypeIdentifierKey] intValue] == type
1097
+ && [feature[NSFontFeatureSelectorIdentifierKey] intValue] == selector) {
1098
+ return true;
1099
+ }
1100
+ }
1101
+ return false;
1102
+ }
1103
+
1104
+ } // namespace
1105
+
1106
+ TEST(appkit_a_font_variant_becomes_core_text_feature_settings) {
1107
+ @autoreleasepool {
1108
+ using facebook::react::FontVariant;
1109
+ // Nothing asked means no settings at all, rather than an empty array that
1110
+ // would rebuild the font for nothing.
1111
+ EXPECT(featuresOf(std::nullopt) == nil || [featuresOf(std::nullopt) count] == 0);
1112
+
1113
+ NSArray *smallCaps = featuresOf(FontVariant::SmallCaps);
1114
+ EXPECT(smallCaps != nil && smallCaps.count == 1);
1115
+ EXPECT(hasFeature(smallCaps, kLowerCaseType, kLowerCaseSmallCapsSelector));
1116
+
1117
+ // The two number cases are the same AAT type and differ by selector, which
1118
+ // is the pair most likely to be swapped.
1119
+ EXPECT(hasFeature(featuresOf(FontVariant::OldstyleNums), kNumberCaseType,
1120
+ kLowerCaseNumbersSelector));
1121
+ EXPECT(hasFeature(featuresOf(FontVariant::LiningNums), kNumberCaseType,
1122
+ kUpperCaseNumbersSelector));
1123
+ EXPECT(hasFeature(featuresOf(FontVariant::TabularNums), kNumberSpacingType,
1124
+ kMonospacedNumbersSelector));
1125
+ EXPECT(hasFeature(featuresOf(FontVariant::ProportionalNums), kNumberSpacingType,
1126
+ kProportionalNumbersSelector));
1127
+
1128
+ NSArray *both = featuresOf(static_cast<FontVariant>(
1129
+ static_cast<int>(FontVariant::SmallCaps) | static_cast<int>(FontVariant::TabularNums)));
1130
+ EXPECT(both != nil && both.count == 2);
1131
+
1132
+ // And that it reaches a real font, which the mapping alone cannot say.
1133
+ // Small caps because the system font has them; a feature it lacks would be
1134
+ // dropped by Core Text and prove nothing either way.
1135
+ NSArray *kept =
1136
+ [fontWith(FontVariant::SmallCaps).fontDescriptor objectForKey:NSFontFeatureSettingsAttribute];
1137
+ EXPECT(kept != nil && hasFeature(kept, kLowerCaseType, kLowerCaseSmallCapsSelector));
1138
+ // A fragment that asked for nothing is left on the plain font.
1139
+ EXPECT([fontWith(FontVariant::Default).fontDescriptor
1140
+ objectForKey:NSFontFeatureSettingsAttribute] == nil);
1141
+ }
1142
+ }
1143
+
1144
+ // The stylistic alternates are a formula rather than twenty table entries, so
1145
+ // the formula is checked against the SDK's own constants rather than against
1146
+ // the comment that states it.
1147
+ TEST(appkit_a_stylistic_alternate_selector_is_twice_its_number) {
1148
+ @autoreleasepool {
1149
+ using facebook::react::FontVariant;
1150
+ EXPECT_EQ(kStylisticAltOneOnSelector, 1 * 2);
1151
+ EXPECT_EQ(kStylisticAltTwentyOnSelector, 20 * 2);
1152
+ EXPECT(hasFeature(featuresOf(FontVariant::StylisticOne), kStylisticAlternativesType,
1153
+ kStylisticAltOneOnSelector));
1154
+ EXPECT(hasFeature(featuresOf(FontVariant::StylisticSeven), kStylisticAlternativesType,
1155
+ kStylisticAltSevenOnSelector));
1156
+ EXPECT(hasFeature(featuresOf(FontVariant::StylisticTwenty), kStylisticAlternativesType,
1157
+ kStylisticAltTwentyOnSelector));
1158
+ }
1159
+ }
1160
+
1161
+ // `TextAttributes::opacity`, which multiplies the alpha of both colours.
1162
+ TEST(appkit_a_fragment_opacity_multiplies_the_colour_alpha) {
1163
+ @autoreleasepool {
1164
+ const auto alphaOf = [](double opacity, bool background) {
1165
+ facebook::react::TextAttributes attributes;
1166
+ attributes.fontSize = 20.0F;
1167
+ attributes.opacity = opacity;
1168
+ attributes.foregroundColor = facebook::react::colorFromComponents(
1169
+ facebook::react::ColorComponents{1.0F, 0.0F, 0.0F, 1.0F});
1170
+ if (background) {
1171
+ attributes.backgroundColor = facebook::react::colorFromComponents(
1172
+ facebook::react::ColorComponents{0.0F, 0.0F, 1.0F, 1.0F});
1173
+ }
1174
+
1175
+ facebook::react::AttributedString::Fragment fragment;
1176
+ fragment.string = "half";
1177
+ fragment.textAttributes = attributes;
1178
+ facebook::react::AttributedString string;
1179
+ string.appendFragment(std::move(fragment));
1180
+
1181
+ NSAttributedString *laid =
1182
+ basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{}).attributedString;
1183
+ NSColor *color = [laid attribute:(background ? NSBackgroundColorAttributeName
1184
+ : NSForegroundColorAttributeName)
1185
+ atIndex:0
1186
+ effectiveRange:nullptr];
1187
+ return color == nil ? -1.0 : (double)color.alphaComponent;
1188
+ };
1189
+
1190
+ EXPECT(std::fabs(alphaOf(1.0, false) - 1.0) < 0.01);
1191
+ EXPECT(std::fabs(alphaOf(0.5, false) - 0.5) < 0.01);
1192
+ // Both colours, which is upstream's rule and not only the foreground.
1193
+ EXPECT(std::fabs(alphaOf(0.25, true) - 0.25) < 0.01);
1194
+ }
1195
+ }
1196
+
1197
+ // `baseWritingDirection`, which decides which edge a paragraph starts from.
1198
+ //
1199
+ // The GTK suite asks the same two questions: that the engine was told, and that
1200
+ // the ink moved. The second matters more than it looks -- on GTK the direction
1201
+ // reached Pango and the glyphs stayed against the left edge, because Pango
1202
+ // flips a natural alignment only when it is deciding the direction itself.
1203
+ // `NSTextAlignmentNatural` has no such condition, which is what this confirms.
1204
+ namespace {
1205
+
1206
+ NSAttributedString *directed(std::optional<facebook::react::WritingDirection> direction) {
1207
+ facebook::react::TextAttributes attributes;
1208
+ attributes.fontSize = 24.0F;
1209
+ attributes.foregroundColor = facebook::react::colorFromComponents(
1210
+ facebook::react::ColorComponents{0.0F, 0.0F, 0.0F, 1.0F});
1211
+ attributes.baseWritingDirection = direction;
1212
+
1213
+ facebook::react::AttributedString::Fragment fragment;
1214
+ fragment.string = "abc";
1215
+ fragment.textAttributes = attributes;
1216
+ facebook::react::AttributedString string;
1217
+ string.appendFragment(std::move(fragment));
1218
+
1219
+ return basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{}).attributedString;
1220
+ }
1221
+
1222
+ } // namespace
1223
+
1224
+ TEST(appkit_a_base_writing_direction_reaches_the_paragraph_style) {
1225
+ @autoreleasepool {
1226
+ using facebook::react::WritingDirection;
1227
+ const auto directionOf = [](std::optional<WritingDirection> asked) {
1228
+ NSParagraphStyle *style = [directed(asked) attribute:NSParagraphStyleAttributeName
1229
+ atIndex:0
1230
+ effectiveRange:nullptr];
1231
+ return style == nil ? NSWritingDirectionNatural : style.baseWritingDirection;
1232
+ };
1233
+
1234
+ EXPECT_EQ(directionOf(WritingDirection::RightToLeft), NSWritingDirectionRightToLeft);
1235
+ EXPECT_EQ(directionOf(WritingDirection::LeftToRight), NSWritingDirectionLeftToRight);
1236
+ // Natural is set rather than skipped: a nested <Text> inherits this style,
1237
+ // and an unset direction would let the enclosing paragraph's stand where
1238
+ // the app asked for the algorithm's answer.
1239
+ EXPECT_EQ(directionOf(WritingDirection::Natural), NSWritingDirectionNatural);
1240
+ }
1241
+ }
1242
+
1243
+ TEST(appkit_a_right_to_left_paragraph_draws_against_the_right_edge) {
1244
+ @autoreleasepool {
1245
+ using facebook::react::WritingDirection;
1246
+ const CGSize size = CGSizeMake(200, 60);
1247
+
1248
+ const auto inkCentre = [&](std::optional<WritingDirection> asked) {
1249
+ RnTextLayout *layout = [RnTextLayout layoutWithAttributedString:directed(asked)
1250
+ maximumNumberOfLines:0
1251
+ truncationType:kCTLineTruncationEnd
1252
+ truncates:YES];
1253
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
1254
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width,
1255
+ (size_t)size.height, 8, 0, space,
1256
+ kCGImageAlphaPremultipliedLast);
1257
+ CGColorSpaceRelease(space);
1258
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
1259
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
1260
+ [layout drawInContext:context size:size];
1261
+
1262
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
1263
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
1264
+ long total = 0;
1265
+ long weight = 0;
1266
+ for (size_t y = 0; y < (size_t)size.height; y++) {
1267
+ for (size_t x = 0; x < (size_t)size.width; x++) {
1268
+ if (pixels[y * stride + x * 4] < 200) { // any ink
1269
+ total += (long)x;
1270
+ weight++;
1271
+ }
1272
+ }
1273
+ }
1274
+ CGContextRelease(context);
1275
+ return weight > 0 ? (double)total / (double)weight : -1.0;
1276
+ };
1277
+
1278
+ const double ltr = inkCentre(WritingDirection::LeftToRight);
1279
+ const double rtl = inkCentre(WritingDirection::RightToLeft);
1280
+ EXPECT(ltr >= 0.0);
1281
+ EXPECT(rtl >= 0.0);
1282
+ EXPECT(ltr < 100.0);
1283
+ EXPECT(rtl > 100.0);
1284
+ }
1285
+ }
1286
+
1287
+ // --- adjustsFontSizeToFit ----------------------------------------------------
1288
+ //
1289
+ // The search is core's and tested there; what these ask is the half that is
1290
+ // Core Text's -- that the ratio reaches the font sizes, that the fitted
1291
+ // paragraph actually fits, and that a paragraph which never asked is untouched.
1292
+ namespace {
1293
+
1294
+ facebook::react::AttributedString label(const std::string &text, float fontSize) {
1295
+ facebook::react::TextAttributes attributes;
1296
+ attributes.fontSize = fontSize;
1297
+
1298
+ facebook::react::AttributedString::Fragment fragment;
1299
+ fragment.string = text;
1300
+ fragment.textAttributes = attributes;
1301
+
1302
+ facebook::react::AttributedString string;
1303
+ string.appendFragment(std::move(fragment));
1304
+ return string;
1305
+ }
1306
+
1307
+ } // namespace
1308
+
1309
+ TEST(appkit_text_adjusts_font_size_to_fit_shrinks_until_it_fits) {
1310
+ @autoreleasepool {
1311
+ const auto text = label("Delete all of the messages", 32.0F);
1312
+
1313
+ facebook::react::ParagraphAttributes fitting;
1314
+ fitting.adjustsFontSizeToFit = true;
1315
+ const basalt::FontFit fit = basalt::textFitScale(text, fitting, 200.0F, 40.0F);
1316
+ EXPECT(fit.scales());
1317
+ EXPECT(fit.ratio < 1.0);
1318
+
1319
+ // And the fitted paragraph fits, measured through the same builder the host
1320
+ // paints with.
1321
+ RnTextLayout *layout = basalt::buildTextLayout(text, fitting, fit);
1322
+ const CGSize size = [layout sizeForWidth:200.0];
1323
+ EXPECT(size.width <= 200.0);
1324
+ EXPECT(size.height <= 40.0);
1325
+ }
1326
+ }
1327
+
1328
+ TEST(appkit_text_a_paragraph_that_did_not_ask_is_not_shrunk) {
1329
+ @autoreleasepool {
1330
+ const auto text = label("Delete all of the messages", 32.0F);
1331
+ facebook::react::ParagraphAttributes plain;
1332
+ EXPECT(!basalt::textFitScale(text, plain, 200.0F, 40.0F).scales());
1333
+
1334
+ RnTextLayout *layout = basalt::buildTextLayout(text, plain);
1335
+ // Wrapped and taller than the box, rather than shrunk into it.
1336
+ EXPECT([layout sizeForWidth:200.0].height > 40.0);
1337
+ }
1338
+ }
1339
+
1340
+ TEST(appkit_text_a_paragraph_that_already_fits_keeps_its_size) {
1341
+ @autoreleasepool {
1342
+ facebook::react::ParagraphAttributes fitting;
1343
+ fitting.adjustsFontSizeToFit = true;
1344
+ EXPECT(!basalt::textFitScale(label("Hi", 16.0F), fitting, 300.0F, 100.0F).scales());
1345
+ }
1346
+ }
1347
+
1348
+ TEST(appkit_text_the_minimum_font_size_stops_the_shrinking) {
1349
+ @autoreleasepool {
1350
+ facebook::react::ParagraphAttributes fitting;
1351
+ fitting.adjustsFontSizeToFit = true;
1352
+ fitting.minimumFontSize = 24.0F;
1353
+ const basalt::FontFit fit =
1354
+ basalt::textFitScale(label("Delete all of the messages", 32.0F), fitting, 60.0F, 30.0F);
1355
+ EXPECT_NEAR(fit.apply(32.0), 24.0, 0.0001);
1356
+ }
1357
+ }
1358
+
1359
+ TEST(appkit_text_a_line_limit_does_not_hide_the_overflow_from_the_search) {
1360
+ @autoreleasepool {
1361
+ // `numberOfLines` with `adjustsFontSizeToFit` is the pairing a button label
1362
+ // is written with, and the reason the search measures the *untruncated*
1363
+ // paragraph: a one-line limit makes a layout that fits any box by throwing
1364
+ // text away.
1365
+ facebook::react::ParagraphAttributes oneLine;
1366
+ oneLine.adjustsFontSizeToFit = true;
1367
+ oneLine.maximumNumberOfLines = 1;
1368
+ const basalt::FontFit fit =
1369
+ basalt::textFitScale(label("Delete all of the messages", 32.0F), oneLine, 200.0F, 40.0F);
1370
+ EXPECT(fit.scales());
1371
+ EXPECT(fit.ratio < 1.0);
1372
+ }
1373
+ }
1374
+
1375
+
1376
+ // --- Which edge the text sits against -----------------------------------------
1377
+ //
1378
+ // The same table the GTK suite asserts, measured the same way -- the leftmost
1379
+ // column with ink in it, in a 300pt box -- because the claim is that the two
1380
+ // hosts agree. `textAlign`'s `start` and `end` are relative to the paragraph's
1381
+ // direction, and this host had four of the eight rows wrong: it folded `left`
1382
+ // into `natural`, folded `end` into `right`, and never resolved the direction
1383
+ // of a paragraph whose text is right-to-left but whose prop said nothing. See
1384
+ // core/TextAlignments.h for what each host used to answer.
1385
+
1386
+ namespace {
1387
+
1388
+ const char *const kHebrewText = "\xd7\xa9\xd7\x9c\xd7\x95\xd7\x9d \xd7\xa2\xd7\x95\xd7\x9c\xd7\x9d";
1389
+
1390
+ // The leftmost column holding ink, with the paragraph drawn into a 300pt box.
1391
+ // Flush left is 0 or 1; flush right is past 200.
1392
+ int inkStartsAt(const char *text,
1393
+ std::optional<facebook::react::WritingDirection> direction,
1394
+ std::optional<facebook::react::TextAlignment> alignment,
1395
+ std::optional<facebook::react::LayoutDirection> layoutDirection = std::nullopt) {
1396
+ const CGSize size = CGSizeMake(300, 40);
1397
+
1398
+ facebook::react::TextAttributes attributes;
1399
+ attributes.fontSize = 16.0F;
1400
+ attributes.alignment = alignment;
1401
+ attributes.baseWritingDirection = direction;
1402
+ // What React Native fills from Yoga for a mirrored app, and what `start` and
1403
+ // `end` are resolved against. See core/TextAlignments.h.
1404
+ attributes.layoutDirection = layoutDirection;
1405
+
1406
+ facebook::react::AttributedString::Fragment fragment;
1407
+ fragment.string = text;
1408
+ fragment.textAttributes = attributes;
1409
+ facebook::react::AttributedString string;
1410
+ string.appendFragment(std::move(fragment));
1411
+
1412
+ RnTextLayout *layout = basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{});
1413
+
1414
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
1415
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height, 8,
1416
+ 0, space, kCGImageAlphaPremultipliedLast);
1417
+ CGColorSpaceRelease(space);
1418
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
1419
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
1420
+ [layout drawInContext:context size:size];
1421
+
1422
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
1423
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
1424
+ int leftmost = -1;
1425
+ for (size_t x = 0; x < (size_t)size.width && leftmost < 0; x++) {
1426
+ for (size_t y = 0; y < (size_t)size.height; y++) {
1427
+ if (pixels[y * stride + x * 4] < 200) {
1428
+ leftmost = (int)x;
1429
+ break;
1430
+ }
1431
+ }
1432
+ }
1433
+ CGContextRelease(context);
1434
+ return leftmost;
1435
+ }
1436
+
1437
+ bool drawnLeft(int x) {
1438
+ return x >= 0 && x < 5;
1439
+ }
1440
+
1441
+ bool drawnRight(int x) {
1442
+ return x > 200;
1443
+ }
1444
+
1445
+ } // namespace
1446
+
1447
+ TEST(appkit_text_align_left_and_right_are_physical_in_both_directions) {
1448
+ @autoreleasepool {
1449
+ using A = facebook::react::TextAlignment;
1450
+ using D = facebook::react::WritingDirection;
1451
+
1452
+ EXPECT(drawnLeft(inkStartsAt("hello world", std::nullopt, A::Left)));
1453
+ EXPECT(drawnRight(inkStartsAt("hello world", std::nullopt, A::Right)));
1454
+
1455
+ // The row this host had wrong: `left` was folded into `natural`, so a
1456
+ // right-to-left paragraph asking for the left edge drew flush right.
1457
+ EXPECT(drawnLeft(inkStartsAt(kHebrewText, D::RightToLeft, A::Left)));
1458
+ EXPECT(drawnRight(inkStartsAt(kHebrewText, D::RightToLeft, A::Right)));
1459
+ EXPECT(drawnLeft(inkStartsAt(kHebrewText, std::nullopt, A::Left)));
1460
+ EXPECT(drawnRight(inkStartsAt(kHebrewText, std::nullopt, A::Right)));
1461
+ }
1462
+ }
1463
+
1464
+ TEST(appkit_text_align_natural_follows_the_paragraph_direction) {
1465
+ @autoreleasepool {
1466
+ using A = facebook::react::TextAlignment;
1467
+ using D = facebook::react::WritingDirection;
1468
+
1469
+ EXPECT(drawnLeft(inkStartsAt("hello world", std::nullopt, A::Natural)));
1470
+ EXPECT(drawnLeft(inkStartsAt("hello world", std::nullopt, std::nullopt)));
1471
+
1472
+ // Resolved from the text, which is what `natural` means and which Core Text
1473
+ // only does inside a frame -- and this layer draws its own lines, so a
1474
+ // paragraph of Hebrew with nothing set drew flush left.
1475
+ EXPECT(drawnRight(inkStartsAt(kHebrewText, std::nullopt, A::Natural)));
1476
+ EXPECT(drawnRight(inkStartsAt(kHebrewText, std::nullopt, std::nullopt)));
1477
+ // And from the prop when there is one.
1478
+ EXPECT(drawnRight(inkStartsAt("hello world", D::RightToLeft, A::Natural)));
1479
+ EXPECT(drawnRight(inkStartsAt("hello world", D::RightToLeft, std::nullopt)));
1480
+ }
1481
+ }
1482
+
1483
+ #if BASALT_RN_MINOR >= 87
1484
+ TEST(appkit_text_align_start_and_end_are_the_edges_the_line_runs_between) {
1485
+ @autoreleasepool {
1486
+ using A = facebook::react::TextAlignment;
1487
+ using D = facebook::react::WritingDirection;
1488
+
1489
+ using L = facebook::react::LayoutDirection;
1490
+
1491
+ EXPECT(drawnLeft(inkStartsAt("hello world", std::nullopt, A::Start)));
1492
+ EXPECT(drawnRight(inkStartsAt("hello world", std::nullopt, A::End)));
1493
+
1494
+ // `end` is the left edge in a paragraph that said it runs right to left,
1495
+ // which is the row this entry opened with.
1496
+ EXPECT(drawnRight(inkStartsAt(kHebrewText, D::RightToLeft, A::Start)));
1497
+ EXPECT(drawnLeft(inkStartsAt(kHebrewText, D::RightToLeft, A::End)));
1498
+ EXPECT(drawnRight(inkStartsAt("hello world", D::RightToLeft, A::Start)));
1499
+ EXPECT(drawnLeft(inkStartsAt("hello world", D::RightToLeft, A::End)));
1500
+
1501
+ // And in a mirrored app, for a paragraph that said nothing: the layout
1502
+ // decides, as it does for `paddingStart` beside it. Latin text included,
1503
+ // which is the case the text alone would get wrong.
1504
+ EXPECT(drawnRight(inkStartsAt("hello world", std::nullopt, A::Start, L::RightToLeft)));
1505
+ EXPECT(drawnLeft(inkStartsAt("hello world", std::nullopt, A::End, L::RightToLeft)));
1506
+
1507
+ // Hebrew in an app that is not mirrored is the other half of it. The text
1508
+ // runs right to left and `start` is still the left edge, because `start`
1509
+ // is the layout's word and this layout is left to right. `natural` is the
1510
+ // one that follows the text, and has its own test above.
1511
+ EXPECT(drawnLeft(inkStartsAt(kHebrewText, std::nullopt, A::Start, L::LeftToRight)));
1512
+ EXPECT(drawnRight(inkStartsAt(kHebrewText, std::nullopt, A::End, L::LeftToRight)));
1513
+ }
1514
+ }
1515
+ #endif
1516
+
1517
+ // --- Where the paragraph sits in its box --------------------------------------
1518
+ //
1519
+ // `textAlignVertical`, the same claim the GTK suite asserts and measured the
1520
+ // same way: the topmost row with ink in it, for a short paragraph in a box far
1521
+ // taller than it needs. Nothing else can show it -- the box, the string and the
1522
+ // measured size are identical whichever end of it the text sits at.
1523
+
1524
+ namespace {
1525
+
1526
+ int inkRowForVerticalFlush(CGFloat flush) {
1527
+ const CGSize size = CGSizeMake(200, 100);
1528
+
1529
+ facebook::react::TextAttributes attributes;
1530
+ attributes.fontSize = 16.0F;
1531
+ facebook::react::AttributedString::Fragment fragment;
1532
+ fragment.string = "Up or down";
1533
+ fragment.textAttributes = attributes;
1534
+ facebook::react::AttributedString string;
1535
+ string.appendFragment(std::move(fragment));
1536
+
1537
+ RnTextLayout *layout = basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{});
1538
+ layout.verticalFlush = flush;
1539
+
1540
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
1541
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height, 8,
1542
+ 0, space, kCGImageAlphaPremultipliedLast);
1543
+ CGColorSpaceRelease(space);
1544
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
1545
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
1546
+ [layout drawInContext:context size:size];
1547
+
1548
+ // Counted down from the *top of the box*, which is not row 0 of the bitmap: a
1549
+ // CGBitmapContext's rows run bottom-up, so the first row of the buffer is the
1550
+ // bottom edge of the picture. The horizontal tests above never had to care.
1551
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
1552
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
1553
+ const int rows = (int)size.height;
1554
+ for (int y = rows - 1; y >= 0; y--) {
1555
+ for (size_t x = 0; x < (size_t)size.width; x++) {
1556
+ if (pixels[(size_t)y * stride + x * 4] < 200) {
1557
+ CGContextRelease(context);
1558
+ return rows - 1 - y;
1559
+ }
1560
+ }
1561
+ }
1562
+ CGContextRelease(context);
1563
+ return -1;
1564
+ }
1565
+
1566
+ } // namespace
1567
+
1568
+ TEST(appkit_text_vertical_align_moves_the_paragraph_down_its_box) {
1569
+ @autoreleasepool {
1570
+ const int top = inkRowForVerticalFlush(0);
1571
+ const int middle = inkRowForVerticalFlush(0.5);
1572
+ const int bottom = inkRowForVerticalFlush(1);
1573
+
1574
+ EXPECT(top >= 0);
1575
+ EXPECT(top < 20);
1576
+ // A 16pt line in a 100pt box leaves about eighty points of slack, so the
1577
+ // three positions are tens of rows apart.
1578
+ EXPECT(middle > top + 20);
1579
+ EXPECT(bottom > middle + 20);
1580
+ EXPECT(bottom > 60);
1581
+ }
1582
+ }
1583
+
1584
+ TEST(appkit_text_vertical_align_a_paragraph_as_tall_as_its_box_does_not_move) {
1585
+ @autoreleasepool {
1586
+ // The clamp: with no slack there is nowhere to go, and a negative offset
1587
+ // would push the first line out through the top of the box.
1588
+ facebook::react::TextAttributes attributes;
1589
+ attributes.fontSize = 16.0F;
1590
+ facebook::react::AttributedString::Fragment fragment;
1591
+ fragment.string = "Nowhere to go";
1592
+ fragment.textAttributes = attributes;
1593
+ facebook::react::AttributedString string;
1594
+ string.appendFragment(std::move(fragment));
1595
+
1596
+ RnTextLayout *layout = basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{});
1597
+ layout.verticalFlush = 1;
1598
+ const CGSize needed = [layout sizeForWidth:200];
1599
+
1600
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
1601
+ CGContextRef context = CGBitmapContextCreate(nullptr, 200, (size_t)needed.height, 8, 0, space,
1602
+ kCGImageAlphaPremultipliedLast);
1603
+ CGColorSpaceRelease(space);
1604
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
1605
+ CGContextFillRect(context, CGRectMake(0, 0, 200, needed.height));
1606
+ [layout drawInContext:context size:CGSizeMake(200, needed.height)];
1607
+
1608
+ // From the top of the box again, so bottom-up through the buffer.
1609
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
1610
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
1611
+ const int rows = (int)needed.height;
1612
+ int topmost = -1;
1613
+ for (int y = rows - 1; y >= 0 && topmost < 0; y--) {
1614
+ for (size_t x = 0; x < 200; x++) {
1615
+ if (pixels[(size_t)y * stride + x * 4] < 200) {
1616
+ topmost = rows - 1 - y;
1617
+ break;
1618
+ }
1619
+ }
1620
+ }
1621
+ CGContextRelease(context);
1622
+ EXPECT(topmost >= 0);
1623
+ EXPECT(topmost < 10);
1624
+ }
1625
+ }
1626
+
1627
+ // `padding` on a `<Text>`, which is not the same prop as padding on a <View>
1628
+ // and was drawn as if it were zero.
1629
+ //
1630
+ // React Native's own LogBox is what found it: the inspector's heading is a
1631
+ // padded <View> around a <Text> and its message is a `<Text>` with
1632
+ // `paddingHorizontal: 12` of its own, so the two lined up on a phone and the
1633
+ // message sat flush against the window edge here. Yoga lays the box out with
1634
+ // the padding either way, which is why nothing about the frame looked wrong,
1635
+ // and React Native offsets an inline view's frame by the same insets in
1636
+ // `ParagraphShadowNode::layout` -- so the text also disagreed with the views
1637
+ // inside it.
1638
+ TEST(appkit_text_padding_moves_the_paragraph_off_its_corner) {
1639
+ @autoreleasepool {
1640
+ const CGSize size = CGSizeMake(200, 80);
1641
+
1642
+ const auto leftmostInk = [&](CGFloat inset) {
1643
+ RnAppKitView *view = [[RnAppKitView alloc] initWithFrame:NSMakeRect(0, 0, 200, 80)];
1644
+ [view setRnTextLayout:layoutFor(@"Inset", 14, 0)];
1645
+ view.rnTextInset = NSEdgeInsetsMake(inset, inset, inset, inset);
1646
+
1647
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
1648
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width,
1649
+ (size_t)size.height, 8, 0, space,
1650
+ kCGImageAlphaPremultipliedLast);
1651
+ CGColorSpaceRelease(space);
1652
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
1653
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
1654
+ // A CGBitmapContext counts y from the bottom and the view counts it from
1655
+ // the top; only the flip below makes `drawRect:` land the right way up.
1656
+ CGContextTranslateCTM(context, 0, size.height);
1657
+ CGContextScaleCTM(context, 1, -1);
1658
+ NSGraphicsContext *previous = NSGraphicsContext.currentContext;
1659
+ NSGraphicsContext.currentContext =
1660
+ [NSGraphicsContext graphicsContextWithCGContext:context flipped:YES];
1661
+ [view drawRect:NSMakeRect(0, 0, size.width, size.height)];
1662
+ NSGraphicsContext.currentContext = previous;
1663
+
1664
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
1665
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
1666
+ int leftmost = -1;
1667
+ for (size_t x = 0; x < (size_t)size.width && leftmost < 0; x++) {
1668
+ for (size_t y = 0; y < (size_t)size.height; y++) {
1669
+ if (pixels[y * stride + x * 4] < 200) { // any ink at all
1670
+ leftmost = (int)x;
1671
+ break;
1672
+ }
1673
+ }
1674
+ }
1675
+ CGContextRelease(context);
1676
+ return leftmost;
1677
+ };
1678
+
1679
+ const int flush = leftmostInk(0);
1680
+ const int padded = leftmostInk(24);
1681
+
1682
+ // Ink at all, or the comparison below would hold for a paragraph that drew
1683
+ // nothing.
1684
+ EXPECT(flush >= 0);
1685
+ // Moved by the padding, give or take the glyph's own left bearing, which is
1686
+ // the same in both and so cancels.
1687
+ EXPECT_EQ((long)(padded - flush), 24L);
1688
+ }
1689
+ }
1690
+
1691
+ // And the hit test moves with it, or a drag would select the character beside
1692
+ // the one under the pointer. Same seam the selection highlight is drawn in.
1693
+ TEST(appkit_text_padding_moves_the_hit_test_too) {
1694
+ @autoreleasepool {
1695
+ RnAppKitView *view = [[RnAppKitView alloc] initWithFrame:NSMakeRect(0, 0, 400, 60)];
1696
+ [view setRnTextLayout:layoutFor(@"Select this sentence", 14, 0)];
1697
+
1698
+ const NSInteger withoutPadding = [view rnTextIndexAtPoint:CGPointMake(60, 8)];
1699
+ view.rnTextInset = NSEdgeInsetsMake(12, 24, 12, 24);
1700
+ // The same character, which is now 24 points further into the view and 12
1701
+ // points down it.
1702
+ const NSInteger withPadding = [view rnTextIndexAtPoint:CGPointMake(84, 20)];
1703
+
1704
+ EXPECT(withoutPadding > 0);
1705
+ EXPECT_EQ((long)withPadding, (long)withoutPadding);
1706
+ }
1707
+ }
1708
+
1709
+ // An inline `<View>` moves with the text it sits in.
1710
+ //
1711
+ // React Native measures an attachment against the content box and places it
1712
+ // from the top -- `ParagraphShadowNode::layout` adds the content insets and
1713
+ // nothing else -- so it knows nothing about `textAlignVertical`, which is each
1714
+ // host's own answer to a box taller than its text. A marker inside a
1715
+ // bottom-aligned sentence therefore stayed at the top of its box while the
1716
+ // words went to the bottom, on all three hosts.
1717
+ TEST(appkit_text_an_inline_view_follows_its_paragraph) {
1718
+ @autoreleasepool {
1719
+ RnAppKitView *paragraph = [[RnAppKitView alloc] initWithFrame:NSMakeRect(0, 0, 200, 90)];
1720
+ RnTextLayout *layout = layoutFor(@"grounded marker", 16, 0);
1721
+ // `textAlignVertical: 'bottom'`, which core/TextAlignments.h turns into a
1722
+ // flush of one.
1723
+ layout.verticalFlush = 1.0;
1724
+ [paragraph setRnTextLayout:layout];
1725
+
1726
+ RnAppKitView *marker = [[RnAppKitView alloc] initWithFrame:NSZeroRect];
1727
+ [paragraph addSubview:marker];
1728
+ // Where React Native put it: the top of the content box, because that is
1729
+ // where the line was measured.
1730
+ [marker setRnFrameX:40 y:0 width:20 height:20];
1731
+
1732
+ const CGFloat offset = [paragraph rnTextVerticalOffset];
1733
+ // A 90 point box holding one line of 16 point text leaves most of itself
1734
+ // below the line, and the text is against the bottom of it.
1735
+ EXPECT(offset > 40);
1736
+ EXPECT_NEAR(marker.frame.origin.y, offset, 0.01);
1737
+
1738
+ // And a paragraph that moves again takes the marker with it: a box that
1739
+ // changed height is a new offset, with nothing new from React Native.
1740
+ [paragraph setRnFrameX:0 y:0 width:200 height:140];
1741
+ const CGFloat taller = [paragraph rnTextVerticalOffset];
1742
+ EXPECT(taller > offset);
1743
+ EXPECT_NEAR(marker.frame.origin.y, taller, 0.01);
1744
+ }
1745
+ }
1746
+
1747
+ // `lineBreakMode`, which React Native's JavaScript spells `lineBreakModeIOS`.
1748
+ //
1749
+ // This host was marked as reading it on the strength of one line --
1750
+ // `style.lineBreakMode = NSLineBreakByWordWrapping` -- which is the opposite:
1751
+ // the paragraph style wraps unconditionally and Core Text takes its truncation
1752
+ // from this project's own layer, which never looked at the prop. So a
1753
+ // `<Text lineBreakModeIOS="tail">` wrapped onto four lines here and cut to one
1754
+ // on the platform the prop is named after.
1755
+ //
1756
+ // Measured the way the GTK suite measures the same claim, by the height of the
1757
+ // laid-out paragraph in a narrow box: a mode that cuts is one line tall
1758
+ // whatever it does with the ellipsis, and the modes that wrap are not.
1759
+ namespace {
1760
+
1761
+ RnTextLayout *brokenBy(std::optional<facebook::react::LineBreakMode> mode) {
1762
+ facebook::react::TextAttributes attributes;
1763
+ attributes.fontSize = 16.0F;
1764
+ attributes.lineBreakMode = mode;
1765
+
1766
+ facebook::react::AttributedString::Fragment fragment;
1767
+ fragment.string = kLong.UTF8String;
1768
+ fragment.textAttributes = attributes;
1769
+ facebook::react::AttributedString string;
1770
+ string.appendFragment(std::move(fragment));
1771
+ return basalt::buildTextLayout(string, facebook::react::ParagraphAttributes{});
1772
+ }
1773
+
1774
+ CGFloat heightWith(std::optional<facebook::react::LineBreakMode> mode) {
1775
+ return [brokenBy(mode) sizeForWidth:200].height;
1776
+ }
1777
+
1778
+ } // namespace
1779
+
1780
+ TEST(appkit_a_truncating_line_break_mode_cuts_the_line_rather_than_wrapping) {
1781
+ @autoreleasepool {
1782
+ using facebook::react::LineBreakMode;
1783
+
1784
+ const CGFloat oneLine = [layoutFor(@"Hello", 16, 0) sizeForWidth:-1].height;
1785
+ const CGFloat wrapped = heightWith(LineBreakMode::Word);
1786
+
1787
+ // The control, and the half that was already right: `word` is React
1788
+ // Native's default and wraps, and so does a fragment that never mentioned
1789
+ // the prop. A fix that truncated every paragraph would be worse than the
1790
+ // gap it closes.
1791
+ EXPECT(wrapped > oneLine * 1.5);
1792
+ EXPECT_NEAR(heightWith(std::nullopt), wrapped, 0.51);
1793
+
1794
+ // The four that cut. The tolerance is a fraction of a line rather than of
1795
+ // a point: an ellipsis can come from a fallback face whose metrics are a
1796
+ // little taller than the one the text is set in.
1797
+ EXPECT_NEAR(heightWith(LineBreakMode::Head), oneLine, 2.0);
1798
+ EXPECT_NEAR(heightWith(LineBreakMode::Middle), oneLine, 2.0);
1799
+ EXPECT_NEAR(heightWith(LineBreakMode::Tail), oneLine, 2.0);
1800
+ EXPECT_NEAR(heightWith(LineBreakMode::Clip), oneLine, 2.0);
1801
+
1802
+ // And `char`, which is a wrap rather than a cut and is the one mode this
1803
+ // host does not honour: wrapping between characters is the typesetter's
1804
+ // business and this layer uses a framesetter. It wraps, which is the half
1805
+ // that matters, and backlog/text.md records the rest.
1806
+ EXPECT(heightWith(LineBreakMode::Char) > oneLine * 1.5);
1807
+ }
1808
+ }