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
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Fonts loaded while the app is running, which is the seam `expo-font` arrives
|
|
2
|
+
// through.
|
|
3
|
+
//
|
|
4
|
+
// The half worth testing is not that Core Text can register a file. It is that
|
|
5
|
+
// the name the app chose keeps working afterwards: a font file carries its own
|
|
6
|
+
// family name inside it, usually not the one the app passed to `loadAsync`, so
|
|
7
|
+
// `fontFamily: 'TheAppsName'` only renders in that font if the registry
|
|
8
|
+
// remembers the mapping and the layout asks it. That mapping is the whole of
|
|
9
|
+
// core/FontRegistry.h, and until now nothing on this host asked it anything.
|
|
10
|
+
//
|
|
11
|
+
// The file is a real one, found through Core Text rather than added to this
|
|
12
|
+
// repository as a fixture. Registering a font the system already has, under a
|
|
13
|
+
// second name of the app's choosing, exercises exactly the mapping this seam
|
|
14
|
+
// exists for and does it on any Mac.
|
|
15
|
+
//
|
|
16
|
+
// A face the system does *not* have is the case `expo-font` really exists for,
|
|
17
|
+
// and it gets its own test at the bottom of this file: tests/RenamedFontFile.h
|
|
18
|
+
// renames the family inside a copy of that same file, which is the one change
|
|
19
|
+
// that makes a font new to the machine.
|
|
20
|
+
|
|
21
|
+
#include "TestHarness.h"
|
|
22
|
+
|
|
23
|
+
#import "CoreTextLayout.h"
|
|
24
|
+
#import "RnTextLayout.h"
|
|
25
|
+
|
|
26
|
+
#include "FontRegistry.h"
|
|
27
|
+
#include "RenamedFontFile.h"
|
|
28
|
+
|
|
29
|
+
#import <AppKit/AppKit.h>
|
|
30
|
+
#import <CoreText/CoreText.h>
|
|
31
|
+
|
|
32
|
+
#include <react/renderer/attributedstring/AttributedString.h>
|
|
33
|
+
#include <react/renderer/textlayoutmanager/TextLayoutManager.h>
|
|
34
|
+
#include <react/utils/ContextContainer.h>
|
|
35
|
+
|
|
36
|
+
#include <cmath>
|
|
37
|
+
#include <filesystem>
|
|
38
|
+
#include <string>
|
|
39
|
+
|
|
40
|
+
namespace {
|
|
41
|
+
|
|
42
|
+
// Monospaced on purpose. The assertions below compare against the proportional
|
|
43
|
+
// system font, and two proportional faces can measure the same string to the
|
|
44
|
+
// same width, which would make a passing test mean nothing.
|
|
45
|
+
NSString *const kKnownFamily = @"Courier New";
|
|
46
|
+
|
|
47
|
+
// Where the system keeps that family's file. `kCTFontURLAttribute` is the only
|
|
48
|
+
// way to ask: a descriptor built from a family name answers with the name, and
|
|
49
|
+
// `registerFont` needs a path.
|
|
50
|
+
std::string pathOfKnownFamily() {
|
|
51
|
+
NSFontDescriptor *wanted = [NSFontDescriptor fontDescriptorWithFontAttributes:@{
|
|
52
|
+
NSFontFamilyAttribute : kKnownFamily,
|
|
53
|
+
}];
|
|
54
|
+
NSArray<NSFontDescriptor *> *matches = [wanted
|
|
55
|
+
matchingFontDescriptorsWithMandatoryKeys:[NSSet setWithObject:NSFontFamilyAttribute]];
|
|
56
|
+
for (NSFontDescriptor *descriptor in matches) {
|
|
57
|
+
NSURL *url = (__bridge_transfer NSURL *)CTFontDescriptorCopyAttribute(
|
|
58
|
+
(__bridge CTFontDescriptorRef)descriptor, kCTFontURLAttribute);
|
|
59
|
+
if (url.isFileURL && url.path.length > 0) {
|
|
60
|
+
return url.path.UTF8String;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
facebook::react::AttributedString paragraphIn(const std::string &family, const char *text) {
|
|
67
|
+
facebook::react::TextAttributes attributes;
|
|
68
|
+
attributes.fontSize = 24.0F;
|
|
69
|
+
attributes.fontFamily = family;
|
|
70
|
+
|
|
71
|
+
facebook::react::AttributedString::Fragment fragment;
|
|
72
|
+
fragment.string = text;
|
|
73
|
+
fragment.textAttributes = attributes;
|
|
74
|
+
|
|
75
|
+
facebook::react::AttributedString string;
|
|
76
|
+
string.appendFragment(std::move(fragment));
|
|
77
|
+
return string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The font Core Text was actually handed for a paragraph asking for `family`,
|
|
81
|
+
// which is the question the registry exists to answer. Through buildTextLayout
|
|
82
|
+
// rather than through fontFor directly, because the path from a prop to a font
|
|
83
|
+
// is what an app's `fontFamily` travels.
|
|
84
|
+
NSString *familyLaidOutFor(const std::string &family) {
|
|
85
|
+
NSAttributedString *built =
|
|
86
|
+
basalt::buildTextLayout(paragraphIn(family, "Handgloves"),
|
|
87
|
+
facebook::react::ParagraphAttributes{})
|
|
88
|
+
.attributedString;
|
|
89
|
+
NSFont *font = [built attribute:NSFontAttributeName atIndex:0 effectiveRange:nullptr];
|
|
90
|
+
return font.familyName;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
} // namespace
|
|
94
|
+
|
|
95
|
+
TEST(appkit_a_font_loaded_at_runtime_resolves_to_the_family_in_the_file) {
|
|
96
|
+
@autoreleasepool {
|
|
97
|
+
const std::string path = pathOfKnownFamily();
|
|
98
|
+
EXPECT(!path.empty());
|
|
99
|
+
|
|
100
|
+
const std::string alias = "BasaltResolvedAlias";
|
|
101
|
+
EXPECT(basalt::registerFont(alias, path));
|
|
102
|
+
EXPECT(basalt::isFontRegistered(alias));
|
|
103
|
+
|
|
104
|
+
// The point of the registry: what the app calls it maps to what the file
|
|
105
|
+
// calls itself, which is the name Core Text indexes by.
|
|
106
|
+
EXPECT_EQ(basalt::resolveFontFamily(alias), std::string(kKnownFamily.UTF8String));
|
|
107
|
+
|
|
108
|
+
// And a name nobody registered comes back unchanged, so a system family
|
|
109
|
+
// reaches Core Text as itself rather than being rewritten.
|
|
110
|
+
EXPECT_EQ(basalt::resolveFontFamily("Helvetica"), std::string("Helvetica"));
|
|
111
|
+
EXPECT(!basalt::isFontRegistered("BasaltNeverLoaded"));
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
TEST(appkit_text_asking_for_a_runtime_family_is_laid_out_in_it) {
|
|
116
|
+
@autoreleasepool {
|
|
117
|
+
const std::string path = pathOfKnownFamily();
|
|
118
|
+
EXPECT(!path.empty());
|
|
119
|
+
|
|
120
|
+
const std::string alias = "BasaltLaidOutAlias";
|
|
121
|
+
EXPECT(basalt::registerFont(alias, path));
|
|
122
|
+
|
|
123
|
+
EXPECT_EQ(std::string(familyLaidOutFor(alias).UTF8String),
|
|
124
|
+
std::string(kKnownFamily.UTF8String));
|
|
125
|
+
|
|
126
|
+
// The negative control, and the reason the assertion above is not vacuous:
|
|
127
|
+
// a family the system does not have falls back to the system font, so a
|
|
128
|
+
// registry that forgot the mapping would land here instead.
|
|
129
|
+
NSString *fallback = familyLaidOutFor("BasaltNeverLoaded");
|
|
130
|
+
EXPECT(![fallback isEqualToString:kKnownFamily]);
|
|
131
|
+
EXPECT_EQ(std::string(fallback.UTF8String),
|
|
132
|
+
std::string([NSFont systemFontOfSize:24].familyName.UTF8String));
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
TEST(appkit_a_font_registered_after_a_measurement_invalidates_it) {
|
|
137
|
+
@autoreleasepool {
|
|
138
|
+
const std::string path = pathOfKnownFamily();
|
|
139
|
+
EXPECT(!path.empty());
|
|
140
|
+
|
|
141
|
+
// This is how every loader works, `useFonts` included: the app renders
|
|
142
|
+
// first, in whatever the fallback is, and the font arrives afterwards. The
|
|
143
|
+
// measurement from before has to stop counting, or the paragraph keeps the
|
|
144
|
+
// fallback's size for the rest of the session and the font appears to have
|
|
145
|
+
// done nothing.
|
|
146
|
+
const std::string alias = "BasaltCacheAlias";
|
|
147
|
+
EXPECT(!basalt::isFontRegistered(alias));
|
|
148
|
+
|
|
149
|
+
const facebook::react::TextLayoutManager manager(
|
|
150
|
+
std::make_shared<const facebook::react::ContextContainer>());
|
|
151
|
+
const auto widthNow = [&] {
|
|
152
|
+
return manager
|
|
153
|
+
.measure(facebook::react::AttributedStringBox{paragraphIn(alias, "Handgloves")},
|
|
154
|
+
facebook::react::ParagraphAttributes{},
|
|
155
|
+
facebook::react::TextLayoutContext{},
|
|
156
|
+
facebook::react::LayoutConstraints{})
|
|
157
|
+
.size.width;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const float fallbackWidth = widthNow();
|
|
161
|
+
EXPECT(fallbackWidth > 0.0F);
|
|
162
|
+
// Measured again with nothing changed, to show the cache is in the way: if
|
|
163
|
+
// the two differed here, the test below would prove nothing.
|
|
164
|
+
EXPECT(std::fabs(widthNow() - fallbackWidth) < 0.01F);
|
|
165
|
+
|
|
166
|
+
const unsigned long before = basalt::fontGeneration();
|
|
167
|
+
EXPECT(basalt::registerFont(alias, path));
|
|
168
|
+
EXPECT(basalt::fontGeneration() != before);
|
|
169
|
+
|
|
170
|
+
EXPECT(std::fabs(widthNow() - fallbackWidth) > 0.5F);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
TEST(appkit_a_font_the_system_does_not_have_becomes_usable) {
|
|
175
|
+
@autoreleasepool {
|
|
176
|
+
const std::string path = pathOfKnownFamily();
|
|
177
|
+
EXPECT(!path.empty());
|
|
178
|
+
|
|
179
|
+
const std::filesystem::path copy =
|
|
180
|
+
std::filesystem::temp_directory_path() / "basalt-appkit-renamed-font.ttf";
|
|
181
|
+
const basalt::test::RenamedFont renamed = basalt::test::renameFontFile(
|
|
182
|
+
path, kKnownFamily.UTF8String, copy.string());
|
|
183
|
+
EXPECT(!renamed.path.empty());
|
|
184
|
+
|
|
185
|
+
// Before: nobody has this family, so a paragraph asking for it by its own
|
|
186
|
+
// name gets the system font. This is the assertion that makes the rest of
|
|
187
|
+
// the file more than a lookup table test -- everything above registers a
|
|
188
|
+
// face Core Text already had.
|
|
189
|
+
EXPECT_EQ(std::string(familyLaidOutFor(renamed.family).UTF8String),
|
|
190
|
+
std::string([NSFont systemFontOfSize:24].familyName.UTF8String));
|
|
191
|
+
|
|
192
|
+
const std::string alias = "BasaltBundledAlias";
|
|
193
|
+
EXPECT(basalt::registerFont(alias, renamed.path));
|
|
194
|
+
EXPECT_EQ(basalt::resolveFontFamily(alias), renamed.family);
|
|
195
|
+
|
|
196
|
+
// After: the app's name reaches it, and so does the family inside the file,
|
|
197
|
+
// which is what `CTFontManagerRegisterFontsForURL` was called for.
|
|
198
|
+
EXPECT_EQ(std::string(familyLaidOutFor(alias).UTF8String), renamed.family);
|
|
199
|
+
EXPECT_EQ(std::string(familyLaidOutFor(renamed.family).UTF8String), renamed.family);
|
|
200
|
+
|
|
201
|
+
std::filesystem::remove(copy);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
|
|
19
19
|
#import "RnAppKitView.h"
|
|
20
20
|
|
|
21
|
+
#include <cmath>
|
|
21
22
|
#include <sstream>
|
|
23
|
+
#include <vector>
|
|
22
24
|
|
|
23
25
|
namespace {
|
|
24
26
|
|
|
@@ -36,18 +38,25 @@ CGImageRef makeImage(size_t width, size_t height) {
|
|
|
36
38
|
return image;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
41
|
+
// Left half black, right half white, opaque. A step edge, which is the one
|
|
42
|
+
// picture a blur is measurable against: how far the ramp spreads is the radius.
|
|
43
|
+
CGImageRef makeSplitImage(size_t width, size_t height) {
|
|
44
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
45
|
+
CGContextRef context = CGBitmapContextCreate(nullptr, width, height, 8, 0, space,
|
|
46
|
+
kCGImageAlphaPremultipliedLast);
|
|
47
|
+
CGColorSpaceRelease(space);
|
|
48
|
+
CGContextSetRGBFillColor(context, 1, 1, 1, 1);
|
|
49
|
+
CGContextFillRect(context, CGRectMake(0, 0, (CGFloat)width, (CGFloat)height));
|
|
50
|
+
CGContextSetRGBFillColor(context, 0, 0, 0, 1);
|
|
51
|
+
CGContextFillRect(context, CGRectMake(0, 0, (CGFloat)width / 2, (CGFloat)height));
|
|
52
|
+
CGImageRef image = CGBitmapContextCreateImage(context);
|
|
53
|
+
CGContextRelease(context);
|
|
54
|
+
return image;
|
|
55
|
+
}
|
|
47
56
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
57
|
+
// The red channel of the view drawn onto white, row-major. Red rather than
|
|
58
|
+
// luminance because everything here is grey or black: the three channels agree.
|
|
59
|
+
std::vector<unsigned char> drawnPixels(RnAppKitView *view, CGSize size) {
|
|
51
60
|
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
52
61
|
CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
|
|
53
62
|
8, 0, space, kCGImageAlphaPremultipliedLast);
|
|
@@ -56,9 +65,7 @@ Ink inkOf(RnAppKitView *view, CGSize size) {
|
|
|
56
65
|
CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
|
|
57
66
|
// A CGBitmapContext counts y from the bottom and this view counts it from the
|
|
58
67
|
// top, and `graphicsContextWithCGContext:flipped:` only *declares* which way
|
|
59
|
-
// up a context is -- it applies no transform.
|
|
60
|
-
// vertically symmetric, so this changes no result; without it the next case
|
|
61
|
-
// that is not symmetric would be read upside down.
|
|
68
|
+
// up a context is -- it applies no transform.
|
|
62
69
|
CGContextTranslateCTM(context, 0, size.height);
|
|
63
70
|
CGContextScaleCTM(context, 1, -1);
|
|
64
71
|
|
|
@@ -68,12 +75,60 @@ Ink inkOf(RnAppKitView *view, CGSize size) {
|
|
|
68
75
|
[view drawRect:NSMakeRect(0, 0, size.width, size.height)];
|
|
69
76
|
NSGraphicsContext.currentContext = previous;
|
|
70
77
|
|
|
71
|
-
auto *
|
|
78
|
+
auto *bytes = static_cast<unsigned char *>(CGBitmapContextGetData(context));
|
|
72
79
|
const size_t stride = CGBitmapContextGetBytesPerRow(context);
|
|
80
|
+
std::vector<unsigned char> red((size_t)size.width * (size_t)size.height);
|
|
81
|
+
for (size_t y = 0; y < (size_t)size.height; y++) {
|
|
82
|
+
for (size_t x = 0; x < (size_t)size.width; x++) {
|
|
83
|
+
red[y * (size_t)size.width + x] = bytes[y * stride + x * 4];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
CGContextRelease(context);
|
|
87
|
+
return red;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// How wide the black-to-white ramp is along the middle row, from 10% to 90%.
|
|
91
|
+
//
|
|
92
|
+
// For a gaussian of standard deviation sigma that width is 2.563 * sigma, which
|
|
93
|
+
// is how a measured number here becomes a statement about the radius: an
|
|
94
|
+
// unblurred edge is one or two pixels and nothing else needs to be known about
|
|
95
|
+
// the filter.
|
|
96
|
+
double rampWidth(RnAppKitView *view, CGSize size) {
|
|
97
|
+
const std::vector<unsigned char> pixels = drawnPixels(view, size);
|
|
98
|
+
const int width = (int)size.width;
|
|
99
|
+
const int row = (int)size.height / 2;
|
|
100
|
+
int low = -1;
|
|
101
|
+
int high = -1;
|
|
102
|
+
for (int x = 0; x < width; x++) {
|
|
103
|
+
const unsigned char value = pixels[(size_t)row * (size_t)width + (size_t)x];
|
|
104
|
+
if (low < 0 && value > 25) {
|
|
105
|
+
low = x;
|
|
106
|
+
}
|
|
107
|
+
if (value > 229) {
|
|
108
|
+
high = x;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return (high > low && low >= 0) ? (double)(high - low) : 0.0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
struct Ink {
|
|
116
|
+
int left{-1};
|
|
117
|
+
int top{-1};
|
|
118
|
+
int right{-1};
|
|
119
|
+
int bottom{-1};
|
|
120
|
+
int width() const { return right < 0 ? 0 : right - left + 1; }
|
|
121
|
+
int height() const { return bottom < 0 ? 0 : bottom - top + 1; }
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Draws the view into a white bitmap and returns the bounding box of anything
|
|
125
|
+
// darker than white.
|
|
126
|
+
Ink inkOf(RnAppKitView *view, CGSize size) {
|
|
127
|
+
const std::vector<unsigned char> pixels = drawnPixels(view, size);
|
|
73
128
|
Ink ink;
|
|
74
129
|
for (int y = 0; y < (int)size.height; y++) {
|
|
75
130
|
for (int x = 0; x < (int)size.width; x++) {
|
|
76
|
-
if (pixels[(size_t)y *
|
|
131
|
+
if (pixels[(size_t)y * (size_t)size.width + (size_t)x] < 128) {
|
|
77
132
|
if (ink.left < 0 || x < ink.left) ink.left = x;
|
|
78
133
|
if (ink.right < 0 || x > ink.right) ink.right = x;
|
|
79
134
|
if (ink.top < 0 || y < ink.top) ink.top = y;
|
|
@@ -81,7 +136,6 @@ Ink inkOf(RnAppKitView *view, CGSize size) {
|
|
|
81
136
|
}
|
|
82
137
|
}
|
|
83
138
|
}
|
|
84
|
-
CGContextRelease(context);
|
|
85
139
|
return ink;
|
|
86
140
|
}
|
|
87
141
|
|
|
@@ -235,3 +289,324 @@ TEST(image_repeat_keeps_the_tile_at_its_natural_size) {
|
|
|
235
289
|
CGImageRelease(image);
|
|
236
290
|
}
|
|
237
291
|
}
|
|
292
|
+
|
|
293
|
+
// `blurRadius` blurs the image, and by the right amount.
|
|
294
|
+
//
|
|
295
|
+
// The amount is the whole point of the test. A blur that is applied in the
|
|
296
|
+
// source image's pixels rather than in the view's coordinates still looks
|
|
297
|
+
// blurred, and still passes a test that only asks whether the edge is soft, but
|
|
298
|
+
// it is a different picture from the GTK side's for every image that is not
|
|
299
|
+
// drawn at its natural size. Here a 40-pixel-wide image is stretched to 200, so
|
|
300
|
+
// a blur in the wrong space would be five times too narrow.
|
|
301
|
+
TEST(image_blur_radius_softens_the_edge_in_view_coordinates) {
|
|
302
|
+
@autoreleasepool {
|
|
303
|
+
CGImageRef image = makeSplitImage(40, 8);
|
|
304
|
+
const CGSize size = CGSizeMake(200, 40);
|
|
305
|
+
|
|
306
|
+
RnAppKitView *sharp = imageView(image, RnAppKitImageFitStretch, size);
|
|
307
|
+
const double unblurred = rampWidth(sharp, size);
|
|
308
|
+
// Four pixels, which is what "no blur" looks like here: one source pixel is
|
|
309
|
+
// five destination pixels wide after the stretch, and the interpolation
|
|
310
|
+
// between them is the whole ramp.
|
|
311
|
+
EXPECT(unblurred <= 5);
|
|
312
|
+
|
|
313
|
+
RnAppKitView *view = imageView(image, RnAppKitImageFitStretch, size);
|
|
314
|
+
[view setRnImageBlur:20];
|
|
315
|
+
// sigma is half the radius and a 10-90 ramp is 2.563 sigma, so a radius of
|
|
316
|
+
// 20 is a ramp near 26 pixels, which is what it measures. The tolerance is
|
|
317
|
+
// wide enough for Core Image to round its kernel differently on a future
|
|
318
|
+
// macOS and narrow enough to fail on a blur applied in the source's own
|
|
319
|
+
// pixels: that one is magnified by the stretch along with the picture and
|
|
320
|
+
// measures 130, which is checked by sabotage rather than guessed at.
|
|
321
|
+
EXPECT_NEAR((int)rampWidth(view, size), 26, 5);
|
|
322
|
+
|
|
323
|
+
CGImageRelease(image);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Zero is no blur, and so is a negative radius: an app can send either and
|
|
328
|
+
// neither is a crash or a filter with a nonsense kernel.
|
|
329
|
+
TEST(image_blur_radius_of_zero_or_less_draws_the_image_itself) {
|
|
330
|
+
@autoreleasepool {
|
|
331
|
+
CGImageRef image = makeSplitImage(40, 8);
|
|
332
|
+
const CGSize size = CGSizeMake(200, 40);
|
|
333
|
+
|
|
334
|
+
RnAppKitView *view = imageView(image, RnAppKitImageFitStretch, size);
|
|
335
|
+
[view setRnImageBlur:20];
|
|
336
|
+
[view setRnImageBlur:0];
|
|
337
|
+
EXPECT((int)rampWidth(view, size) <= 5);
|
|
338
|
+
|
|
339
|
+
[view setRnImageBlur:-20];
|
|
340
|
+
EXPECT((int)rampWidth(view, size) <= 5);
|
|
341
|
+
|
|
342
|
+
CGImageRelease(image);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// A tinted image is a silhouette filled with one colour, and a blurred tinted
|
|
347
|
+
// image is that silhouette with soft edges. The mask is what gets blurred here,
|
|
348
|
+
// which is the same picture as blurring the fill because the colour is
|
|
349
|
+
// constant, and it is what the GTK side does by pushing its blur around the
|
|
350
|
+
// tint rather than inside it.
|
|
351
|
+
TEST(image_blur_radius_applies_to_a_tinted_image_too) {
|
|
352
|
+
@autoreleasepool {
|
|
353
|
+
// Opaque black on the left, transparent on the right: with a tint it is the
|
|
354
|
+
// alpha that decides where the colour lands.
|
|
355
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
356
|
+
CGContextRef bitmap = CGBitmapContextCreate(nullptr, 40, 8, 8, 0, space,
|
|
357
|
+
kCGImageAlphaPremultipliedLast);
|
|
358
|
+
CGColorSpaceRelease(space);
|
|
359
|
+
CGContextSetRGBFillColor(bitmap, 0, 0, 0, 1);
|
|
360
|
+
CGContextFillRect(bitmap, CGRectMake(0, 0, 20, 8));
|
|
361
|
+
CGImageRef image = CGBitmapContextCreateImage(bitmap);
|
|
362
|
+
CGContextRelease(bitmap);
|
|
363
|
+
|
|
364
|
+
const CGSize size = CGSizeMake(200, 40);
|
|
365
|
+
RnAppKitView *view = imageView(image, RnAppKitImageFitStretch, size);
|
|
366
|
+
[view setRnImageTint:[NSColor colorWithSRGBRed:0 green:0 blue:0 alpha:1]];
|
|
367
|
+
EXPECT((int)rampWidth(view, size) <= 5);
|
|
368
|
+
|
|
369
|
+
[view setRnImageBlur:20];
|
|
370
|
+
EXPECT_NEAR((int)rampWidth(view, size), 26, 5);
|
|
371
|
+
|
|
372
|
+
CGImageRelease(image);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Clearing the image must not leave the blurred copy of it behind to be drawn
|
|
377
|
+
// against the next one, and a blur with no image at all is nothing rather than a
|
|
378
|
+
// crash.
|
|
379
|
+
TEST(image_blur_survives_the_image_being_replaced) {
|
|
380
|
+
@autoreleasepool {
|
|
381
|
+
const CGSize size = CGSizeMake(200, 40);
|
|
382
|
+
RnAppKitView *view = [RnAppKitView viewWithTag:1];
|
|
383
|
+
[view setRnFrameX:0 y:0 width:size.width height:size.height];
|
|
384
|
+
[view setRnImageBlur:20];
|
|
385
|
+
EXPECT_EQ(inkOf(view, size).width(), 0);
|
|
386
|
+
|
|
387
|
+
CGImageRef image = makeSplitImage(40, 8);
|
|
388
|
+
[view setRnImage:image fit:RnAppKitImageFitStretch];
|
|
389
|
+
EXPECT_NEAR((int)rampWidth(view, size), 26, 5);
|
|
390
|
+
|
|
391
|
+
[view setRnImage:nullptr fit:RnAppKitImageFitStretch];
|
|
392
|
+
EXPECT_EQ(inkOf(view, size).width(), 0);
|
|
393
|
+
CGImageRelease(image);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// And the radius is in the tree dump, spelled as GTK spells it.
|
|
398
|
+
//
|
|
399
|
+
// Which is what makes the wiring testable rather than only the drawing: the
|
|
400
|
+
// prop is read in a branch of AppKitMountingManager that knows ImageProps, and
|
|
401
|
+
// every test above sets the blur on the view by hand, so all of them pass while
|
|
402
|
+
// the mounting manager drops it on the floor. The GTK side did exactly that.
|
|
403
|
+
TEST(image_blur_is_reported_in_the_tree) {
|
|
404
|
+
@autoreleasepool {
|
|
405
|
+
CGImageRef image = makeImage(4, 2);
|
|
406
|
+
RnAppKitView *view = imageView(image, RnAppKitImageFitStretch, CGSizeMake(50, 50));
|
|
407
|
+
CGImageRelease(image);
|
|
408
|
+
|
|
409
|
+
EXPECT(![[view describeTree] containsString:@"blur="]);
|
|
410
|
+
[view setRnImageBlur:8];
|
|
411
|
+
EXPECT([[view describeTree] containsString:@"blur=8"]);
|
|
412
|
+
[view setRnImageBlur:0];
|
|
413
|
+
EXPECT(![[view describeTree] containsString:@"blur="]);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// Animated images.
|
|
419
|
+
//
|
|
420
|
+
// The frames come out of a real GIF rather than from bitmaps built here: the
|
|
421
|
+
// delays and the loop count are in the file's own bytes, and reading them is
|
|
422
|
+
// half of what this platform had to learn. The file is four pixels square,
|
|
423
|
+
// ninety-five bytes, and inline for the same reason the still fixtures above
|
|
424
|
+
// are -- it needs no asset pipeline and no fixture to keep in step.
|
|
425
|
+
//
|
|
426
|
+
// Written with ImageMagick:
|
|
427
|
+
//
|
|
428
|
+
// magick -loop 0 -delay 8 -size 4x4 xc:'#ff0000' \
|
|
429
|
+
// -delay 4 -size 4x4 xc:'#0000ff' anim.gif
|
|
430
|
+
//
|
|
431
|
+
// so frame one is red for 80ms and frame two is blue for 40ms, forever. The
|
|
432
|
+
// delays differ on purpose: equal ones hide an engine that uses the first
|
|
433
|
+
// frame's delay for all of them.
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
namespace {
|
|
436
|
+
|
|
437
|
+
const char *const kAnimatedGifBase64 =
|
|
438
|
+
"R0lGODlhBAAEAPAAAP8AAAAAACH/C05FVFNDQVBFMi4wAwEAAAAh+QQACAAAACwAAAAABAAEAAAC"
|
|
439
|
+
"BISPCQUAIfkEAAQAAAAsAAAAAAQABACAAAD/AAAAAgSEjwkFADs=";
|
|
440
|
+
|
|
441
|
+
CGImageSourceRef animatedGifSource() {
|
|
442
|
+
NSData *bytes = [[NSData alloc] initWithBase64EncodedString:@(kAnimatedGifBase64) options:0];
|
|
443
|
+
return CGImageSourceCreateWithData((__bridge CFDataRef)bytes, nullptr);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// The frames of that GIF, as the view takes them.
|
|
447
|
+
NSArray *animatedGifFrames() {
|
|
448
|
+
CGImageSourceRef source = animatedGifSource();
|
|
449
|
+
NSMutableArray *frames = [NSMutableArray array];
|
|
450
|
+
for (size_t index = 0; index < CGImageSourceGetCount(source); index++) {
|
|
451
|
+
CGImageRef frame = CGImageSourceCreateImageAtIndex(source, index, nullptr);
|
|
452
|
+
if (frame != nullptr) {
|
|
453
|
+
[frames addObject:(__bridge_transfer id)frame];
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
CFRelease(source);
|
|
457
|
+
return frames;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// The colour at the centre of the view, drawn onto white. All three channels,
|
|
461
|
+
// unlike `drawnPixels` above: red and blue differ in two of them and agree in
|
|
462
|
+
// the green, so one channel cannot tell these frames apart.
|
|
463
|
+
struct Rgb {
|
|
464
|
+
unsigned char red{0};
|
|
465
|
+
unsigned char green{0};
|
|
466
|
+
unsigned char blue{0};
|
|
467
|
+
};
|
|
468
|
+
|
|
469
|
+
Rgb centreColour(RnAppKitView *view, CGSize size) {
|
|
470
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
471
|
+
CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
|
|
472
|
+
8, 0, space, kCGImageAlphaPremultipliedLast);
|
|
473
|
+
CGColorSpaceRelease(space);
|
|
474
|
+
CGContextSetRGBFillColor(context, 1, 1, 1, 1);
|
|
475
|
+
CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
|
|
476
|
+
CGContextTranslateCTM(context, 0, size.height);
|
|
477
|
+
CGContextScaleCTM(context, 1, -1);
|
|
478
|
+
|
|
479
|
+
NSGraphicsContext *previous = NSGraphicsContext.currentContext;
|
|
480
|
+
NSGraphicsContext.currentContext = [NSGraphicsContext graphicsContextWithCGContext:context
|
|
481
|
+
flipped:YES];
|
|
482
|
+
[view drawRect:NSMakeRect(0, 0, size.width, size.height)];
|
|
483
|
+
NSGraphicsContext.currentContext = previous;
|
|
484
|
+
|
|
485
|
+
auto *bytes = static_cast<unsigned char *>(CGBitmapContextGetData(context));
|
|
486
|
+
const size_t stride = CGBitmapContextGetBytesPerRow(context);
|
|
487
|
+
const size_t row = (size_t)size.height / 2;
|
|
488
|
+
const size_t column = (size_t)size.width / 2;
|
|
489
|
+
const Rgb colour{bytes[row * stride + column * 4], bytes[row * stride + column * 4 + 1],
|
|
490
|
+
bytes[row * stride + column * 4 + 2]};
|
|
491
|
+
CGContextRelease(context);
|
|
492
|
+
return colour;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
RnAppKitView *animatedView(NSArray *frames, NSArray<NSNumber *> *delays, NSUInteger loops) {
|
|
496
|
+
RnAppKitView *view = [RnAppKitView viewWithTag:1];
|
|
497
|
+
[view setRnFrameX:0 y:0 width:20 height:20];
|
|
498
|
+
[view setRnImage:(__bridge CGImageRef)frames.firstObject fit:RnAppKitImageFitStretch];
|
|
499
|
+
[view setRnImageFrames:frames delaysMs:delays loopCount:loops];
|
|
500
|
+
return view;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
} // namespace
|
|
504
|
+
|
|
505
|
+
TEST(animated_image_the_frame_follows_the_time_it_is_advanced_by) {
|
|
506
|
+
@autoreleasepool {
|
|
507
|
+
NSArray *frames = animatedGifFrames();
|
|
508
|
+
EXPECT_EQ((int)frames.count, 2);
|
|
509
|
+
|
|
510
|
+
RnAppKitView *view = animatedView(frames, @[@80, @40], 0);
|
|
511
|
+
const CGSize size = CGSizeMake(20, 20);
|
|
512
|
+
|
|
513
|
+
// Frame one, red, which is also what a still <Image> of this file draws.
|
|
514
|
+
EXPECT(centreColour(view, size).red > 200);
|
|
515
|
+
EXPECT(centreColour(view, size).blue < 60);
|
|
516
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 0);
|
|
517
|
+
|
|
518
|
+
// Still frame one at 79ms: the delay is the file's and not a guess.
|
|
519
|
+
[view rnAdvanceImageAnimationBy:79];
|
|
520
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 0);
|
|
521
|
+
|
|
522
|
+
// Frame two, blue, one millisecond later.
|
|
523
|
+
[view rnAdvanceImageAnimationBy:1];
|
|
524
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 1);
|
|
525
|
+
EXPECT(centreColour(view, size).blue > 200);
|
|
526
|
+
EXPECT(centreColour(view, size).red < 60);
|
|
527
|
+
|
|
528
|
+
// And round again after the second frame's own, shorter delay: 40ms, not
|
|
529
|
+
// another 80.
|
|
530
|
+
[view rnAdvanceImageAnimationBy:40];
|
|
531
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 0);
|
|
532
|
+
EXPECT(centreColour(view, size).red > 200);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
TEST(animated_image_says_how_long_until_the_next_frame) {
|
|
537
|
+
@autoreleasepool {
|
|
538
|
+
RnAppKitView *view = animatedView(animatedGifFrames(), @[@80, @40], 0);
|
|
539
|
+
|
|
540
|
+
// What the host's timer is set to, which is why this is answered rather
|
|
541
|
+
// than kept private: at the start of frame one, the whole 80ms.
|
|
542
|
+
EXPECT_NEAR([view rnAdvanceImageAnimationBy:0], 80.0, 0.01);
|
|
543
|
+
EXPECT_NEAR([view rnAdvanceImageAnimationBy:30], 50.0, 0.01);
|
|
544
|
+
// Into frame two, which is 40ms long.
|
|
545
|
+
EXPECT_NEAR([view rnAdvanceImageAnimationBy:50], 40.0 - 0.0, 0.01);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
TEST(animated_image_a_loop_count_stops_on_the_last_frame) {
|
|
550
|
+
@autoreleasepool {
|
|
551
|
+
RnAppKitView *view = animatedView(animatedGifFrames(), @[@80, @40], 1);
|
|
552
|
+
const CGSize size = CGSizeMake(20, 20);
|
|
553
|
+
|
|
554
|
+
// One pass is 120ms. After it the animation stops, on frame two, and says
|
|
555
|
+
// nothing more is due -- which is what stops the timer.
|
|
556
|
+
EXPECT_NEAR([view rnAdvanceImageAnimationBy:120], 0.0, 0.01);
|
|
557
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 1);
|
|
558
|
+
EXPECT(centreColour(view, size).blue > 200);
|
|
559
|
+
|
|
560
|
+
[view rnAdvanceImageAnimationBy:1000];
|
|
561
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 1);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
TEST(animated_image_a_single_frame_is_a_still_image) {
|
|
566
|
+
@autoreleasepool {
|
|
567
|
+
NSArray *frames = animatedGifFrames();
|
|
568
|
+
RnAppKitView *view = animatedView(@[frames.firstObject], @[@80], 0);
|
|
569
|
+
|
|
570
|
+
EXPECT_NEAR([view rnAdvanceImageAnimationBy:500], 0.0, 0.01);
|
|
571
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 0);
|
|
572
|
+
EXPECT(![[view describeTree] containsString:@"animated=1"]);
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// A re-mount must not restart the animation, which is the bug this shape of
|
|
577
|
+
// mounting manager invites: every mutation that touches an <Image> re-applies
|
|
578
|
+
// the first frame, a layout-only one included, so a spinner would stutter back
|
|
579
|
+
// to the top on every resize.
|
|
580
|
+
TEST(animated_image_survives_the_props_being_reapplied) {
|
|
581
|
+
@autoreleasepool {
|
|
582
|
+
NSArray *frames = animatedGifFrames();
|
|
583
|
+
RnAppKitView *view = animatedView(frames, @[@80, @40], 0);
|
|
584
|
+
|
|
585
|
+
[view rnAdvanceImageAnimationBy:100];
|
|
586
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 1);
|
|
587
|
+
|
|
588
|
+
// What the mounting manager does on a mutation: the first frame again, and
|
|
589
|
+
// the same arrays the loader cached.
|
|
590
|
+
[view setRnImage:(__bridge CGImageRef)frames.firstObject fit:RnAppKitImageFitStretch];
|
|
591
|
+
[view setRnImageFrames:frames delaysMs:@[@80, @40] loopCount:0];
|
|
592
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 1);
|
|
593
|
+
EXPECT(centreColour(view, CGSizeMake(20, 20)).blue > 200);
|
|
594
|
+
|
|
595
|
+
// A different animation does start over, which is the same check from the
|
|
596
|
+
// other side: the frames array is what tells them apart.
|
|
597
|
+
[view setRnImageFrames:animatedGifFrames() delaysMs:@[@80, @40] loopCount:0];
|
|
598
|
+
EXPECT_EQ((int)view.rnImageFrameIndex, 0);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
TEST(animated_image_is_reported_in_the_tree) {
|
|
603
|
+
@autoreleasepool {
|
|
604
|
+
RnAppKitView *view = animatedView(animatedGifFrames(), @[@80, @40], 0);
|
|
605
|
+
EXPECT([[view describeTree] containsString:@"animated=1"]);
|
|
606
|
+
|
|
607
|
+
// Cleared with the frames, so an <Image> whose source becomes a still one
|
|
608
|
+
// does not keep claiming to move.
|
|
609
|
+
[view setRnImageFrames:nil delaysMs:nil loopCount:0];
|
|
610
|
+
EXPECT(![[view describeTree] containsString:@"animated=1"]);
|
|
611
|
+
}
|
|
612
|
+
}
|