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.
- package/native/CMakeLists.txt +16 -2
- package/native/appkit/AppKitAppWindow.h +24 -0
- package/native/appkit/AppKitImageLoader.h +48 -1
- package/native/appkit/AppKitImageLoader.mm +228 -10
- package/native/appkit/AppKitMenuBar.mm +58 -0
- package/native/appkit/AppKitMountingManager.h +34 -0
- package/native/appkit/AppKitMountingManager.mm +582 -12
- package/native/appkit/AppKitScrollView.h +48 -0
- package/native/appkit/AppKitScrollView.mm +210 -0
- package/native/appkit/AppKitSkiaPeer.h +15 -0
- package/native/appkit/AppKitTextInput.h +38 -1
- package/native/appkit/AppKitTextInput.mm +235 -8
- package/native/appkit/AppKitTextPeer.h +60 -0
- package/native/appkit/AppKitTextPeer.mm +307 -0
- package/native/appkit/AppKitTouchDispatcher.h +56 -0
- package/native/appkit/AppKitTouchDispatcher.mm +140 -0
- package/native/appkit/AppKitWindowControl.mm +38 -3
- package/native/appkit/ComponentRegistryAppKit.mm +28 -3
- package/native/appkit/CoreTextLayout.h +64 -2
- package/native/appkit/CoreTextLayout.mm +431 -71
- package/native/appkit/CoreTextLayoutManager.mm +57 -6
- package/native/appkit/FontRegistryCoreText.mm +6 -5
- package/native/appkit/PlatformServicesAppKit.mm +101 -20
- package/native/appkit/RnAppKitView.h +492 -0
- package/native/appkit/RnAppKitView.mm +2266 -23
- package/native/appkit/RnTextLayout.h +106 -0
- package/native/appkit/RnTextLayout.mm +321 -2
- package/native/appkit/main_appkit.mm +174 -3
- package/native/tests/test_appkit_accessibility.mm +289 -0
- package/native/tests/test_appkit_borders.mm +975 -0
- package/native/tests/test_appkit_fonts.mm +203 -0
- package/native/tests/test_appkit_image.mm +392 -17
- package/native/tests/test_appkit_input.mm +209 -0
- package/native/tests/test_appkit_mounting.mm +360 -0
- package/native/tests/test_appkit_scrollbar.mm +125 -0
- package/native/tests/test_appkit_scrollview.mm +371 -0
- package/native/tests/test_appkit_selection.mm +349 -0
- package/native/tests/test_appkit_services.mm +51 -1
- package/native/tests/test_appkit_text.mm +1628 -0
- package/native/tests/test_appkit_textinput.mm +819 -0
- package/native/tests/test_appkit_titlebar.mm +43 -0
- package/native/tests/test_appkit_view.mm +609 -0
- package/package.json +4 -3
|
@@ -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
|
+
}
|