basalt-appkit 0.0.0-rc5

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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runMacos.js +33 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +298 -0
  5. package/native/appkit/AppKitAnimationChoreographer.h +54 -0
  6. package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
  7. package/native/appkit/AppKitDropTarget.h +34 -0
  8. package/native/appkit/AppKitDropTarget.mm +292 -0
  9. package/native/appkit/AppKitFocus.h +120 -0
  10. package/native/appkit/AppKitFocus.mm +293 -0
  11. package/native/appkit/AppKitImageLoader.h +80 -0
  12. package/native/appkit/AppKitImageLoader.mm +156 -0
  13. package/native/appkit/AppKitKeyEvents.h +26 -0
  14. package/native/appkit/AppKitKeyEvents.mm +97 -0
  15. package/native/appkit/AppKitMenuBar.mm +424 -0
  16. package/native/appkit/AppKitMountingManager.h +135 -0
  17. package/native/appkit/AppKitMountingManager.mm +946 -0
  18. package/native/appkit/AppKitRunLoopObserver.h +34 -0
  19. package/native/appkit/AppKitRunLoopObserver.mm +42 -0
  20. package/native/appkit/AppKitScrollView.h +168 -0
  21. package/native/appkit/AppKitScrollView.mm +550 -0
  22. package/native/appkit/AppKitSkiaContext.h +33 -0
  23. package/native/appkit/AppKitSkiaContext.mm +255 -0
  24. package/native/appkit/AppKitSkiaModule.h +53 -0
  25. package/native/appkit/AppKitSkiaModule.mm +81 -0
  26. package/native/appkit/AppKitSkiaPeer.h +37 -0
  27. package/native/appkit/AppKitSkiaPeer.mm +123 -0
  28. package/native/appkit/AppKitSkiaView.h +62 -0
  29. package/native/appkit/AppKitSnapshot.h +21 -0
  30. package/native/appkit/AppKitSnapshot.mm +91 -0
  31. package/native/appkit/AppKitTextInput.h +140 -0
  32. package/native/appkit/AppKitTextInput.mm +588 -0
  33. package/native/appkit/AppKitTextPeer.h +74 -0
  34. package/native/appkit/AppKitTextPeer.mm +353 -0
  35. package/native/appkit/AppKitTitleBar.h +90 -0
  36. package/native/appkit/AppKitTitleBar.mm +186 -0
  37. package/native/appkit/AppKitTouchDispatcher.h +138 -0
  38. package/native/appkit/AppKitTouchDispatcher.mm +514 -0
  39. package/native/appkit/AppKitWindowControl.mm +222 -0
  40. package/native/appkit/AppKitWindowModule.h +62 -0
  41. package/native/appkit/AppKitWindowModule.mm +194 -0
  42. package/native/appkit/ColorSchemeAppKit.mm +71 -0
  43. package/native/appkit/ComponentRegistryAppKit.mm +121 -0
  44. package/native/appkit/CoreTextLayout.h +43 -0
  45. package/native/appkit/CoreTextLayout.mm +252 -0
  46. package/native/appkit/CoreTextLayoutManager.mm +138 -0
  47. package/native/appkit/FontRegistryCoreText.mm +148 -0
  48. package/native/appkit/PlatformServicesAppKit.mm +540 -0
  49. package/native/appkit/RnAppKitView.h +417 -0
  50. package/native/appkit/RnAppKitView.mm +1605 -0
  51. package/native/appkit/RnTextLayout.h +38 -0
  52. package/native/appkit/RnTextLayout.mm +168 -0
  53. package/native/appkit/demo_layout_appkit.mm +93 -0
  54. package/native/appkit/main_appkit.mm +1860 -0
  55. package/native/appkit/mount_harness_appkit.mm +197 -0
  56. package/native/tests/test_appkit_accessibility.mm +170 -0
  57. package/native/tests/test_appkit_image.mm +237 -0
  58. package/native/tests/test_appkit_input.mm +852 -0
  59. package/native/tests/test_appkit_key_events.mm +150 -0
  60. package/native/tests/test_appkit_mounting.mm +430 -0
  61. package/native/tests/test_appkit_scrollbar.mm +132 -0
  62. package/native/tests/test_appkit_services.mm +57 -0
  63. package/native/tests/test_appkit_skia.mm +212 -0
  64. package/native/tests/test_appkit_text.mm +180 -0
  65. package/native/tests/test_appkit_textinput.mm +748 -0
  66. package/native/tests/test_appkit_titlebar.mm +107 -0
  67. package/native/tests/test_appkit_view.mm +237 -0
  68. package/package.json +26 -0
  69. package/react-native.config.js +22 -0
@@ -0,0 +1,132 @@
1
+ // The overlay scrollbar, on the macOS view layer.
2
+ //
3
+ // core/ScrollIndicator.h is where the geometry is decided, and
4
+ // tests/test_scroll_indicator.cpp already checks the arithmetic. What is left
5
+ // is the part that is only true on this platform: AppKit draws subviews *over*
6
+ // their superview, and a ScrollView always has one -- its content -- covering
7
+ // the whole of it. So the thumb is a view of its own, kept last among the
8
+ // subviews, which is AppKit's definition of topmost.
9
+ //
10
+ // That is the decision worth a test. A thumb drawn in the scroll view's own
11
+ // `drawRect:` would compute exactly the same numbers, report exactly the same
12
+ // dump line, and be invisible behind the list.
13
+
14
+ #include "ScrollIndicator.h"
15
+ #include "TestHarness.h"
16
+
17
+ #import "RnAppKitView.h"
18
+
19
+ namespace {
20
+
21
+ // The view the scrollbars are drawn into, which is deliberately not an
22
+ // RnAppKitView -- that is what keeps it out of the paint order, out of hit
23
+ // testing and out of the tree dump's children.
24
+ NSView *indicatorViewOf(RnAppKitView *view) {
25
+ NSView *last = view.subviews.lastObject;
26
+ if (last == nil || [last isKindOfClass:[RnAppKitView class]]) {
27
+ return nil;
28
+ }
29
+ return last;
30
+ }
31
+
32
+ RnAppKitView *scrollViewWithIndicators(CGFloat width, CGFloat height) {
33
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
34
+ [view setRnFrameX:0 y:0 width:width height:height];
35
+ // A hundred-point window onto four hundred points of content, which
36
+ // core/ScrollIndicator.h turns into a quarter-length thumb.
37
+ const basalt::ScrollIndicator vertical = basalt::scrollIndicatorFor(height, height * 4, 0);
38
+ [view setRnScrollIndicatorVerticalOffset:vertical.offset
39
+ verticalLength:vertical.length
40
+ horizontalOffset:0
41
+ horizontalLength:0];
42
+ return view;
43
+ }
44
+
45
+ // Draws one view into a white bitmap and answers whether anything landed on the
46
+ // pixel asked for. Alpha 0.35 black over white is mid-grey, so "darker than
47
+ // white" is the whole test -- nothing here pins a colour.
48
+ bool inkAt(NSView *view, CGSize size, int atX, int atY) {
49
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
50
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height, 8,
51
+ 0, space, kCGImageAlphaPremultipliedLast);
52
+ CGColorSpaceRelease(space);
53
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
54
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
55
+ // A CGBitmapContext counts y from the bottom and this view counts it from the
56
+ // top, and `graphicsContextWithCGContext:flipped:` only *declares* which way
57
+ // up a context is -- it applies no transform. Without this the assertions
58
+ // below would be upside down, and a thumb at the bottom would pass a test
59
+ // written for one at the top.
60
+ CGContextTranslateCTM(context, 0, size.height);
61
+ CGContextScaleCTM(context, 1, -1);
62
+
63
+ NSGraphicsContext *previous = NSGraphicsContext.currentContext;
64
+ NSGraphicsContext.currentContext = [NSGraphicsContext graphicsContextWithCGContext:context
65
+ flipped:YES];
66
+ [view drawRect:NSMakeRect(0, 0, size.width, size.height)];
67
+ NSGraphicsContext.currentContext = previous;
68
+
69
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
70
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
71
+ const bool marked = pixels[(size_t)atY * stride + (size_t)atX * 4] < 250;
72
+ CGContextRelease(context);
73
+ return marked;
74
+ }
75
+
76
+ } // namespace
77
+
78
+ // The thumb exists, and it is the last subview -- which is the one AppKit draws
79
+ // on top of the others.
80
+ TEST(appkit_scrollbar_is_the_topmost_subview) {
81
+ RnAppKitView *view = scrollViewWithIndicators(100, 100);
82
+ NSView *indicators = indicatorViewOf(view);
83
+ EXPECT(indicators != nil);
84
+
85
+ // A content view arrives, as one always does: React Native wraps a
86
+ // ScrollView's children in exactly one.
87
+ RnAppKitView *content = [RnAppKitView viewWithTag:2];
88
+ [content setRnFrameX:0 y:0 width:100 height:400];
89
+ [view insertRnChild:content atIndex:0];
90
+ EXPECT(indicatorViewOf(view) == indicators);
91
+
92
+ // And again past the end of the list, which is the insert that appends.
93
+ RnAppKitView *second = [RnAppKitView viewWithTag:3];
94
+ [view insertRnChild:second atIndex:5];
95
+ EXPECT(indicatorViewOf(view) == indicators);
96
+ }
97
+
98
+ // It is paint and nothing else: a press goes to the content underneath.
99
+ TEST(appkit_scrollbar_is_not_a_hit_target) {
100
+ RnAppKitView *view = scrollViewWithIndicators(100, 100);
101
+ NSView *indicators = indicatorViewOf(view);
102
+ EXPECT([indicators hitTest:NSMakePoint(97, 10)] == nil);
103
+ }
104
+
105
+ // The thumb lands at the trailing edge, at the top, and nowhere else.
106
+ TEST(appkit_scrollbar_draws_at_the_trailing_edge) {
107
+ RnAppKitView *view = scrollViewWithIndicators(100, 100);
108
+ NSView *indicators = indicatorViewOf(view);
109
+ EXPECT(indicators != nil);
110
+
111
+ const CGSize size = CGSizeMake(100, 100);
112
+ // Inside the bar: two in from the right edge, three more into its six points
113
+ // of thickness, and ten down -- which is inside a thumb a quarter of 96 long.
114
+ EXPECT(inkAt(indicators, size, 95, 10));
115
+ // The middle of the view, where a scrollbar has no business being.
116
+ EXPECT(!inkAt(indicators, size, 50, 50));
117
+ // Past the end of the thumb: it starts at the top, so the bottom is bare.
118
+ EXPECT(!inkAt(indicators, size, 95, 90));
119
+ }
120
+
121
+ // Content that fits takes the whole thing away again, rather than leaving a
122
+ // zero-length bar behind.
123
+ TEST(appkit_scrollbar_goes_away_when_everything_fits) {
124
+ RnAppKitView *view = scrollViewWithIndicators(100, 100);
125
+ EXPECT(indicatorViewOf(view) != nil);
126
+
127
+ [view setRnScrollIndicatorVerticalOffset:0
128
+ verticalLength:0
129
+ horizontalOffset:0
130
+ horizontalLength:0];
131
+ EXPECT(indicatorViewOf(view) == nil);
132
+ }
@@ -0,0 +1,57 @@
1
+ // Tests for macOS's half of the platform-services seam.
2
+ //
3
+ // The clipboard and URL halves only. The alert is tested from JavaScript
4
+ // instead; see the note further down for why it cannot be tested here.
5
+
6
+ #include "TestHarness.h"
7
+
8
+ #include "PlatformServices.h"
9
+
10
+ #import <Cocoa/Cocoa.h>
11
+
12
+ #include <sstream>
13
+ #include <string>
14
+
15
+ TEST(clipboard_round_trips) {
16
+ @autoreleasepool {
17
+ const std::string original = basalt::clipboardText();
18
+
19
+ basalt::setClipboardText("basalt round trip");
20
+ EXPECT_EQ(basalt::clipboardText(), std::string("basalt round trip"));
21
+
22
+ // Unicode, because NSPasteboard is UTF-16 inside and the conversion either
23
+ // way is where a clipboard loses characters.
24
+ basalt::setClipboardText("héllo · 世界");
25
+ EXPECT_EQ(basalt::clipboardText(), std::string("héllo · 世界"));
26
+
27
+ // Put back whatever was there: this is the user's clipboard, not the
28
+ // suite's.
29
+ basalt::setClipboardText(original);
30
+ }
31
+ }
32
+
33
+ TEST(can_open_url_answers_by_scheme) {
34
+ @autoreleasepool {
35
+ // Something is always registered for http on a Mac.
36
+ EXPECT(basalt::canOpenUrl("https://example.com"));
37
+ EXPECT(!basalt::canOpenUrl("zzznotascheme:whatever"));
38
+ EXPECT(!basalt::canOpenUrl(""));
39
+ EXPECT(!basalt::canOpenUrl("not a url at all"));
40
+ }
41
+ }
42
+
43
+ // There is no test here for the alert, deliberately.
44
+ //
45
+ // A sheet attaches to a key or main window, and a command-line binary's
46
+ // activation policy is `prohibited` -- its windows can never become either. So
47
+ // showAlert falls back to a modal run loop, which is right in the real case it
48
+ // exists for (an alert before the first window is on screen) and puts a real
49
+ // dialog on the developer's actual desktop when a test does it. It did, once,
50
+ // while this was being written.
51
+ //
52
+ // What the alert needs proving about it is that it does not block: it is called
53
+ // from the JavaScript thread, and a modal run loop there would freeze every
54
+ // mount, timer and animation frame until somebody clicked. That is checked by
55
+ // js/alert.js instead, which keeps a 300ms heartbeat running and shows an alert
56
+ // half a second in -- the ticks carry straight on past it, which a blocking
57
+ // implementation could not do.
@@ -0,0 +1,212 @@
1
+ // That Skia actually renders on this host, rather than merely linking.
2
+ //
3
+ // `RNSkiaModule: Skia installed` says the bindings were constructed. It does not
4
+ // say a pixel can be drawn, and the two are a long way apart: a Metal device that
5
+ // fails to create, a GrDirectContext that comes back null, a surface that wraps a
6
+ // texture nothing can read -- each of those installs perfectly well and then
7
+ // produces nothing. So this draws a known shape into an offscreen surface and
8
+ // reads the pixels back out.
9
+ //
10
+ // Against real pixels rather than against the objects returned, for the reason
11
+ // test_appkit_image.mm gives about resize modes: a call that returned a plausible
12
+ // surface and drew nowhere would pass either way. What is asserted is where the
13
+ // ink landed.
14
+ //
15
+ // Only compiled when the build was pointed at an app that has the package, so
16
+ // these run on a developer's machine and not in CI -- which has no app and so no
17
+ // Skia. See ../../basalt-core/native/cmake/Skia.cmake.
18
+
19
+ #include "TestHarness.h"
20
+
21
+ #include "AppKitSkiaContext.h"
22
+
23
+ #include "include/core/SkCanvas.h"
24
+ #include "include/core/SkColor.h"
25
+ #include "include/core/SkFontMgr.h"
26
+ #include "include/core/SkImage.h"
27
+ #include "include/core/SkImageInfo.h"
28
+ #include "include/core/SkPaint.h"
29
+ #include "include/core/SkPath.h"
30
+ #include "include/core/SkPathBuilder.h"
31
+ #include "include/core/SkRect.h"
32
+ #include "include/core/SkSurface.h"
33
+ #include "include/gpu/ganesh/GrDirectContext.h"
34
+
35
+ #include <cstdint>
36
+ #include <memory>
37
+ #include <sstream>
38
+ #include <string>
39
+ #include <vector>
40
+
41
+ namespace {
42
+
43
+ constexpr int kSize = 64;
44
+
45
+ // The context as the module builds it, minus the call invoker. Nothing here
46
+ // crosses to the JavaScript thread, so a null one is honest: it would be used by
47
+ // runOnJavascriptThread, which no test below reaches.
48
+ std::shared_ptr<RNSkia::RNSkPlatformContext> context() {
49
+ static std::shared_ptr<RNSkia::RNSkPlatformContext> shared =
50
+ basalt::makeSkiaPlatformContext(nullptr);
51
+ return shared;
52
+ }
53
+
54
+ // One pixel, read back from a GPU surface. Premultiplied BGRA is what
55
+ // MakeOffscreen's texture is, and readPixels converts into whatever is asked
56
+ // for -- RGBA here, so the assertions below read in the order the names suggest.
57
+ struct Pixel {
58
+ uint8_t r = 0, g = 0, b = 0, a = 0;
59
+
60
+ std::string describe() const {
61
+ std::ostringstream out;
62
+ out << "rgba(" << (int)r << "," << (int)g << "," << (int)b << "," << (int)a << ")";
63
+ return out.str();
64
+ }
65
+ };
66
+
67
+ std::vector<Pixel> readBack(const sk_sp<SkSurface> &surface) {
68
+ sk_sp<SkImage> snapshot = surface->makeImageSnapshot();
69
+ if (snapshot == nullptr) {
70
+ return {};
71
+ }
72
+ const SkImageInfo info =
73
+ SkImageInfo::Make(kSize, kSize, kRGBA_8888_SkColorType, kPremul_SkAlphaType);
74
+ std::vector<Pixel> pixels(static_cast<size_t>(kSize) * kSize);
75
+ // Through the direct context, because the image is texture-backed: the
76
+ // no-context overload returns false for one and the failure looks like an
77
+ // all-zero bitmap, which is also what a surface nothing drew into looks like.
78
+ if (!snapshot->readPixels(context()->getDirectContext(), info, pixels.data(),
79
+ kSize * sizeof(Pixel), 0, 0)) {
80
+ return {};
81
+ }
82
+ return pixels;
83
+ }
84
+
85
+ const Pixel &at(const std::vector<Pixel> &pixels, int x, int y) {
86
+ return pixels[static_cast<size_t>(y) * kSize + x];
87
+ }
88
+
89
+ } // namespace
90
+
91
+ TEST(skia_has_a_gpu_context) {
92
+ // If this is null nothing below can work, and the reason is a Metal device
93
+ // that would not create rather than anything about Skia.
94
+ EXPECT(context()->getDirectContext() != nullptr);
95
+ }
96
+
97
+ TEST(skia_makes_an_offscreen_surface) {
98
+ // The call an app renders frames for export through; see kino's
99
+ // export/renderFrame.tsx, which is Skia.Surface.MakeOffscreen.
100
+ sk_sp<SkSurface> surface = context()->makeOffscreenSurface(kSize, kSize);
101
+ EXPECT(surface != nullptr);
102
+ EXPECT_EQ(surface->width(), kSize);
103
+ EXPECT_EQ(surface->height(), kSize);
104
+ }
105
+
106
+ TEST(skia_draws_a_rectangle_where_it_was_asked_to) {
107
+ sk_sp<SkSurface> surface = context()->makeOffscreenSurface(kSize, kSize);
108
+ EXPECT(surface != nullptr);
109
+
110
+ SkCanvas *canvas = surface->getCanvas();
111
+ canvas->clear(SK_ColorTRANSPARENT);
112
+ SkPaint paint;
113
+ paint.setColor(SK_ColorRED);
114
+ // A quarter of the surface, offset from the origin so that "drew something"
115
+ // and "drew it in the right place" are different assertions.
116
+ canvas->drawRect(SkRect::MakeXYWH(16, 16, 32, 32), paint);
117
+
118
+ const std::vector<Pixel> pixels = readBack(surface);
119
+ EXPECT(!pixels.empty());
120
+ if (pixels.empty()) {
121
+ return;
122
+ }
123
+
124
+ // Inside: red, opaque.
125
+ const Pixel &inside = at(pixels, 32, 32);
126
+ EXPECT_EQ(inside.describe(), std::string("rgba(255,0,0,255)"));
127
+
128
+ // Outside, well clear of the edge: nothing was drawn, so nothing is there.
129
+ // Not tested right at the boundary -- antialiasing puts an edge half a pixel
130
+ // either way, and pinning that fails on a future Skia for no useful reason.
131
+ const Pixel &outside = at(pixels, 4, 4);
132
+ EXPECT_EQ(outside.describe(), std::string("rgba(0,0,0,0)"));
133
+ }
134
+
135
+ TEST(skia_draws_a_path) {
136
+ // Paths are the other half of what an app actually uses -- kino imports Path,
137
+ // Rect, RoundedRect and RadialGradient -- and they go through a different
138
+ // part of Skia than a rectangle fill.
139
+ sk_sp<SkSurface> surface = context()->makeOffscreenSurface(kSize, kSize);
140
+ EXPECT(surface != nullptr);
141
+
142
+ SkCanvas *canvas = surface->getCanvas();
143
+ canvas->clear(SK_ColorTRANSPARENT);
144
+ SkPaint paint;
145
+ paint.setColor(SK_ColorGREEN);
146
+ paint.setAntiAlias(false);
147
+ // A triangle filling the lower left half, so the two diagonal corners differ.
148
+ //
149
+ // Through SkPathBuilder: this Skia has no SkPath::moveTo. Worth knowing when
150
+ // porting anything that touches paths -- the mutating API moved and the old
151
+ // one is gone rather than deprecated.
152
+ SkPathBuilder builder;
153
+ builder.moveTo(0, 0);
154
+ builder.lineTo(0, kSize);
155
+ builder.lineTo(kSize, kSize);
156
+ builder.close();
157
+ canvas->drawPath(builder.detach(), paint);
158
+
159
+ const std::vector<Pixel> pixels = readBack(surface);
160
+ EXPECT(!pixels.empty());
161
+ if (pixels.empty()) {
162
+ return;
163
+ }
164
+ // Inside the triangle, and outside it. The corners rather than near the
165
+ // hypotenuse, for the antialiasing reason above.
166
+ EXPECT_EQ(at(pixels, 4, 60).describe(), std::string("rgba(0,255,0,255)"));
167
+ EXPECT_EQ(at(pixels, 60, 4).describe(), std::string("rgba(0,0,0,0)"));
168
+ }
169
+
170
+ TEST(skia_finds_the_system_fonts) {
171
+ // CoreText through Skia's own font manager, which is what Text and matchFont
172
+ // resolve against. A manager with no families is the shape this fails in.
173
+ sk_sp<SkFontMgr> fonts = context()->createFontMgr();
174
+ EXPECT(fonts != nullptr);
175
+ EXPECT(fonts->countFamilies() > 0);
176
+
177
+ // And the host's own list, which the package asks for by a different route.
178
+ EXPECT(!context()->getSystemFontFamilies().empty());
179
+ }
180
+
181
+ TEST(skia_says_which_calls_it_does_not_implement) {
182
+ // The three that throw do so with their own name in the message. Asserted
183
+ // because the alternative -- a blank image or a zero handle -- reads as a
184
+ // rendering bug rather than as a missing feature, which is the whole reason
185
+ // they throw.
186
+ bool threw = false;
187
+ std::string message;
188
+ try {
189
+ context()->takeScreenshotFromViewTag(7);
190
+ } catch (const std::exception &error) {
191
+ threw = true;
192
+ message = error.what();
193
+ }
194
+ EXPECT(threw);
195
+ EXPECT(message.find("not implemented") != std::string::npos);
196
+ EXPECT(message.find("7") != std::string::npos);
197
+
198
+ threw = false;
199
+ try {
200
+ context()->makeTestNativeBuffer(8, 8);
201
+ } catch (const std::exception &error) {
202
+ threw = true;
203
+ message = error.what();
204
+ }
205
+ EXPECT(threw);
206
+ EXPECT(message.find("CVPixelBuffer") != std::string::npos);
207
+
208
+ // And the one that deliberately does not throw: releasing a buffer nothing
209
+ // handed out must be quiet, because throwing from a release path turns a leak
210
+ // into a crash.
211
+ context()->releaseNativeBuffer(0);
212
+ }
@@ -0,0 +1,180 @@
1
+ // Tests for the Core Text layer: does a paragraph measure and truncate the way
2
+ // React Native means it to?
3
+ //
4
+ // Against RnTextLayout directly, with no Fabric and no React Native, for the
5
+ // same reason the view tests are: this is the half that can be wrong on its
6
+ // own, and a failure here is much easier to read than the same failure arriving
7
+ // as a paragraph that is the wrong height on screen.
8
+ //
9
+ // Nothing here asserts an exact pixel size. Core Text over the system font is
10
+ // not reproducible across macOS versions, and a test that pinned it would fail
11
+ // on somebody else's machine for no useful reason. What is asserted is the
12
+ // relationships that must hold whatever the font is.
13
+
14
+ #include "TestHarness.h"
15
+
16
+ #import "RnTextLayout.h"
17
+
18
+ #include <sstream>
19
+
20
+ namespace {
21
+
22
+ NSAttributedString *styled(NSString *text, CGFloat size, NSTextAlignment alignment) {
23
+ NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init];
24
+ style.alignment = alignment;
25
+ style.lineBreakMode = NSLineBreakByWordWrapping;
26
+ return [[NSAttributedString alloc] initWithString:text
27
+ attributes:@{
28
+ NSFontAttributeName : [NSFont systemFontOfSize:size],
29
+ NSParagraphStyleAttributeName : style,
30
+ }];
31
+ }
32
+
33
+ RnTextLayout *layoutFor(NSString *text, CGFloat size, NSInteger lines) {
34
+ return [RnTextLayout layoutWithAttributedString:styled(text, size, NSTextAlignmentNatural)
35
+ maximumNumberOfLines:lines
36
+ truncationType:kCTLineTruncationEnd
37
+ truncates:YES];
38
+ }
39
+
40
+ NSString *const kLong =
41
+ @"A paragraph long enough to wrap several times over, so that the line breaking has real "
42
+ @"work to do and a line limit has something to cut.";
43
+
44
+ } // namespace
45
+
46
+ TEST(text_measures_something) {
47
+ @autoreleasepool {
48
+ RnTextLayout *layout = layoutFor(@"Hello", 16, 0);
49
+ const CGSize size = [layout sizeForWidth:-1];
50
+
51
+ // The stub this replaces returned the minimum size, which is how text had
52
+ // no size at all on this platform until now.
53
+ EXPECT(size.width > 0);
54
+ EXPECT(size.height > 0);
55
+ }
56
+ }
57
+
58
+ TEST(text_at_a_bigger_size_measures_bigger) {
59
+ @autoreleasepool {
60
+ const CGSize small = [layoutFor(@"Hello", 12, 0) sizeForWidth:-1];
61
+ const CGSize large = [layoutFor(@"Hello", 32, 0) sizeForWidth:-1];
62
+
63
+ EXPECT(large.width > small.width);
64
+ EXPECT(large.height > small.height);
65
+ }
66
+ }
67
+
68
+ TEST(text_wraps_when_it_is_given_a_width) {
69
+ @autoreleasepool {
70
+ RnTextLayout *layout = layoutFor(kLong, 16, 0);
71
+
72
+ const CGSize unconstrained = [layout sizeForWidth:-1];
73
+ const CGSize narrow = [layout sizeForWidth:200];
74
+
75
+ // Wrapping trades width for height. Both halves matter: a layout that
76
+ // ignored the width would keep its height, and one that ignored the text
77
+ // would keep its width.
78
+ EXPECT(narrow.width <= 200);
79
+ EXPECT(narrow.height > unconstrained.height);
80
+ }
81
+ }
82
+
83
+ TEST(number_of_lines_limits_the_height) {
84
+ @autoreleasepool {
85
+ const CGSize unlimited = [layoutFor(kLong, 16, 0) sizeForWidth:200];
86
+ const CGSize twoLines = [layoutFor(kLong, 16, 2) sizeForWidth:200];
87
+ const CGSize oneLine = [layoutFor(kLong, 16, 1) sizeForWidth:200];
88
+
89
+ EXPECT(twoLines.height < unlimited.height);
90
+ EXPECT(oneLine.height < twoLines.height);
91
+
92
+ // Two lines is two lines tall, not "whatever fits". Core Text has no line
93
+ // limit of its own -- RnTextLayout applies it by keeping the first N lines
94
+ // -- so this is the assertion that the limit is really being applied rather
95
+ // than the text happening to be short.
96
+ EXPECT_NEAR(twoLines.height, oneLine.height * 2, 2.0);
97
+ }
98
+ }
99
+
100
+ // A limit that drops nothing must not add an ellipsis. Getting this wrong is
101
+ // invisible until a paragraph that exactly fits gets one anyway.
102
+ TEST(a_line_limit_that_fits_is_not_truncated) {
103
+ @autoreleasepool {
104
+ const CGSize unlimited = [layoutFor(@"Short", 16, 0) sizeForWidth:400];
105
+ const CGSize limited = [layoutFor(@"Short", 16, 3) sizeForWidth:400];
106
+
107
+ EXPECT_NEAR(limited.width, unlimited.width, 0.51);
108
+ EXPECT_NEAR(limited.height, unlimited.height, 0.51);
109
+ }
110
+ }
111
+
112
+ // Alignment changes where a line is drawn, never how big it is. A paragraph
113
+ // that measured differently when centred would make Yoga lay it out wrong.
114
+ TEST(alignment_does_not_change_the_measured_size) {
115
+ @autoreleasepool {
116
+ const auto measure = [](NSTextAlignment alignment) {
117
+ RnTextLayout *layout =
118
+ [RnTextLayout layoutWithAttributedString:styled(kLong, 16, alignment)
119
+ maximumNumberOfLines:0
120
+ truncationType:kCTLineTruncationEnd
121
+ truncates:YES];
122
+ return [layout sizeForWidth:300];
123
+ };
124
+
125
+ const CGSize natural = measure(NSTextAlignmentNatural);
126
+ const CGSize centred = measure(NSTextAlignmentCenter);
127
+ const CGSize right = measure(NSTextAlignmentRight);
128
+
129
+ EXPECT_NEAR(centred.height, natural.height, 0.51);
130
+ EXPECT_NEAR(right.height, natural.height, 0.51);
131
+ }
132
+ }
133
+
134
+ // The drawing path, against a real bitmap: a laid-out paragraph has to put ink
135
+ // on the page, and it has to put it in the half of the box the alignment says.
136
+ TEST(text_draws_where_the_alignment_says) {
137
+ @autoreleasepool {
138
+ const CGSize size = CGSizeMake(300, 40);
139
+
140
+ const auto inkColumns = [&](NSTextAlignment alignment) {
141
+ RnTextLayout *layout =
142
+ [RnTextLayout layoutWithAttributedString:styled(@"Edge", 16, alignment)
143
+ maximumNumberOfLines:0
144
+ truncationType:kCTLineTruncationEnd
145
+ truncates:YES];
146
+
147
+ CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
148
+ CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
149
+ 8, 0, space, kCGImageAlphaPremultipliedLast);
150
+ CGColorSpaceRelease(space);
151
+ CGContextSetRGBFillColor(context, 1, 1, 1, 1);
152
+ CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
153
+ [layout drawInContext:context size:size];
154
+
155
+ auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
156
+ const size_t stride = CGBitmapContextGetBytesPerRow(context);
157
+ int leftmost = -1;
158
+ for (size_t x = 0; x < (size_t)size.width && leftmost < 0; x++) {
159
+ for (size_t y = 0; y < (size_t)size.height; y++) {
160
+ if (pixels[y * stride + x * 4] < 200) { // any ink at all
161
+ leftmost = (int)x;
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ CGContextRelease(context);
167
+ return leftmost;
168
+ };
169
+
170
+ const int left = inkColumns(NSTextAlignmentNatural);
171
+ const int centred = inkColumns(NSTextAlignmentCenter);
172
+ const int right = inkColumns(NSTextAlignmentRight);
173
+
174
+ // Ink at all, first: a paragraph that drew nothing would pass every size
175
+ // assertion above.
176
+ EXPECT(left >= 0);
177
+ EXPECT(centred > left);
178
+ EXPECT(right > centred);
179
+ }
180
+ }