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.
- package/LICENSE +21 -0
- package/cli/runMacos.js +33 -0
- package/cli/shared.js +75 -0
- package/native/CMakeLists.txt +298 -0
- package/native/appkit/AppKitAnimationChoreographer.h +54 -0
- package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
- package/native/appkit/AppKitDropTarget.h +34 -0
- package/native/appkit/AppKitDropTarget.mm +292 -0
- package/native/appkit/AppKitFocus.h +120 -0
- package/native/appkit/AppKitFocus.mm +293 -0
- package/native/appkit/AppKitImageLoader.h +80 -0
- package/native/appkit/AppKitImageLoader.mm +156 -0
- package/native/appkit/AppKitKeyEvents.h +26 -0
- package/native/appkit/AppKitKeyEvents.mm +97 -0
- package/native/appkit/AppKitMenuBar.mm +424 -0
- package/native/appkit/AppKitMountingManager.h +135 -0
- package/native/appkit/AppKitMountingManager.mm +946 -0
- package/native/appkit/AppKitRunLoopObserver.h +34 -0
- package/native/appkit/AppKitRunLoopObserver.mm +42 -0
- package/native/appkit/AppKitScrollView.h +168 -0
- package/native/appkit/AppKitScrollView.mm +550 -0
- package/native/appkit/AppKitSkiaContext.h +33 -0
- package/native/appkit/AppKitSkiaContext.mm +255 -0
- package/native/appkit/AppKitSkiaModule.h +53 -0
- package/native/appkit/AppKitSkiaModule.mm +81 -0
- package/native/appkit/AppKitSkiaPeer.h +37 -0
- package/native/appkit/AppKitSkiaPeer.mm +123 -0
- package/native/appkit/AppKitSkiaView.h +62 -0
- package/native/appkit/AppKitSnapshot.h +21 -0
- package/native/appkit/AppKitSnapshot.mm +91 -0
- package/native/appkit/AppKitTextInput.h +140 -0
- package/native/appkit/AppKitTextInput.mm +588 -0
- package/native/appkit/AppKitTextPeer.h +74 -0
- package/native/appkit/AppKitTextPeer.mm +353 -0
- package/native/appkit/AppKitTitleBar.h +90 -0
- package/native/appkit/AppKitTitleBar.mm +186 -0
- package/native/appkit/AppKitTouchDispatcher.h +138 -0
- package/native/appkit/AppKitTouchDispatcher.mm +514 -0
- package/native/appkit/AppKitWindowControl.mm +222 -0
- package/native/appkit/AppKitWindowModule.h +62 -0
- package/native/appkit/AppKitWindowModule.mm +194 -0
- package/native/appkit/ColorSchemeAppKit.mm +71 -0
- package/native/appkit/ComponentRegistryAppKit.mm +121 -0
- package/native/appkit/CoreTextLayout.h +43 -0
- package/native/appkit/CoreTextLayout.mm +252 -0
- package/native/appkit/CoreTextLayoutManager.mm +138 -0
- package/native/appkit/FontRegistryCoreText.mm +148 -0
- package/native/appkit/PlatformServicesAppKit.mm +540 -0
- package/native/appkit/RnAppKitView.h +417 -0
- package/native/appkit/RnAppKitView.mm +1605 -0
- package/native/appkit/RnTextLayout.h +38 -0
- package/native/appkit/RnTextLayout.mm +168 -0
- package/native/appkit/demo_layout_appkit.mm +93 -0
- package/native/appkit/main_appkit.mm +1860 -0
- package/native/appkit/mount_harness_appkit.mm +197 -0
- package/native/tests/test_appkit_accessibility.mm +170 -0
- package/native/tests/test_appkit_image.mm +237 -0
- package/native/tests/test_appkit_input.mm +852 -0
- package/native/tests/test_appkit_key_events.mm +150 -0
- package/native/tests/test_appkit_mounting.mm +430 -0
- package/native/tests/test_appkit_scrollbar.mm +132 -0
- package/native/tests/test_appkit_services.mm +57 -0
- package/native/tests/test_appkit_skia.mm +212 -0
- package/native/tests/test_appkit_text.mm +180 -0
- package/native/tests/test_appkit_textinput.mm +748 -0
- package/native/tests/test_appkit_titlebar.mm +107 -0
- package/native/tests/test_appkit_view.mm +237 -0
- package/package.json +26 -0
- 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
|
+
}
|