basalt-appkit 0.0.0-rc5 → 0.1.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 +576 -9
- package/native/appkit/AppKitScrollView.h +48 -0
- package/native/appkit/AppKitScrollView.mm +210 -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 +20 -0
- 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
package/native/CMakeLists.txt
CHANGED
|
@@ -129,8 +129,10 @@ target_include_directories(basalt_appkit_view PUBLIC appkit)
|
|
|
129
129
|
# so anything it takes from there has to be `inline`.
|
|
130
130
|
target_include_directories(basalt_appkit_view PUBLIC
|
|
131
131
|
${CMAKE_CURRENT_SOURCE_DIR}/../../basalt-core/native/core)
|
|
132
|
+
# CoreImage is here for one prop: an <Image>'s `blurRadius`, which Core Graphics
|
|
133
|
+
# has no filter of its own for. See RnBlurredImageCreate.
|
|
132
134
|
target_link_libraries(basalt_appkit_view PUBLIC
|
|
133
|
-
"-framework Cocoa" "-framework QuartzCore" "-framework CoreText")
|
|
135
|
+
"-framework Cocoa" "-framework QuartzCore" "-framework CoreText" "-framework CoreImage")
|
|
134
136
|
|
|
135
137
|
add_executable(demo_layout_appkit appkit/demo_layout_appkit.mm)
|
|
136
138
|
target_compile_options(demo_layout_appkit PRIVATE -Wall -Wextra -fobjc-arc)
|
|
@@ -144,9 +146,13 @@ set(BASALT_APPKIT_TEST_SOURCES
|
|
|
144
146
|
# Core Text, against RnTextLayout alone. No React Native, so these run in the
|
|
145
147
|
# toolkit-only build too.
|
|
146
148
|
tests/test_appkit_text.mm
|
|
149
|
+
tests/test_appkit_selection.mm
|
|
147
150
|
# The resize modes, against a real bitmap. No React Native, so these run in
|
|
148
151
|
# the toolkit-only build too.
|
|
149
152
|
tests/test_appkit_image.mm
|
|
153
|
+
# Dotted and dashed borders, also against a bitmap: what a border is drawn as
|
|
154
|
+
# rather than what the view was told.
|
|
155
|
+
tests/test_appkit_borders.mm
|
|
150
156
|
# The overlay scrollbar's platform half: that it is a view above the content
|
|
151
157
|
# rather than paint behind it. No React Native, so these run in the
|
|
152
158
|
# toolkit-only build too.
|
|
@@ -269,6 +275,10 @@ if(TARGET basalt_core)
|
|
|
269
275
|
# package header it was never given the include path for.
|
|
270
276
|
${BASALT_PACKAGE_TESTS}
|
|
271
277
|
tests/test_appkit_mounting.mm
|
|
278
|
+
# Fonts loaded at runtime: core/FontRegistry.h's macOS half, and the
|
|
279
|
+
# measurement cache a late registration has to invalidate. Needs React
|
|
280
|
+
# Native, for the AttributedString a paragraph is measured from.
|
|
281
|
+
tests/test_appkit_fonts.mm
|
|
272
282
|
# Hit testing lives with the touch dispatcher, which the host builds; the
|
|
273
283
|
# tests need it too, and it pulls in nothing else.
|
|
274
284
|
appkit/AppKitFocus.mm
|
|
@@ -279,7 +289,11 @@ if(TARGET basalt_core)
|
|
|
279
289
|
tests/test_appkit_input.mm
|
|
280
290
|
tests/test_appkit_key_events.mm
|
|
281
291
|
# <TextInput>'s controlled-value loop, through real mutations.
|
|
282
|
-
tests/test_appkit_textinput.mm
|
|
292
|
+
tests/test_appkit_textinput.mm
|
|
293
|
+
# <ScrollView>'s props, as far as they reach without a window: the manager
|
|
294
|
+
# takes a view and an emitter lookup, so the offset a prop asks for is
|
|
295
|
+
# observable with no display at all.
|
|
296
|
+
tests/test_appkit_scrollview.mm)
|
|
283
297
|
endif()
|
|
284
298
|
|
|
285
299
|
add_executable(basalt_appkit_tests ${BASALT_APPKIT_TEST_SOURCES})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The window this process owns, for the parts of the host that need it and are
|
|
2
|
+
// not the window module.
|
|
3
|
+
//
|
|
4
|
+
// `NSApp.keyWindow` and `NSApp.mainWindow` are both nil whenever the app is not
|
|
5
|
+
// the active one, and two different things were asking only those: a bounds read
|
|
6
|
+
// answered 0x0, and a menu role sent through the responder chain went nowhere.
|
|
7
|
+
// Both are what an app looks like when it was launched while something else had
|
|
8
|
+
// focus, which is most of the time under a test harness and some of the time on
|
|
9
|
+
// a desktop.
|
|
10
|
+
//
|
|
11
|
+
// So the rule lives in one place. It is GTK's rule, from
|
|
12
|
+
// GtkWindowControl.cpp: prefer the active window, fall back to the first visible
|
|
13
|
+
// one that is not a panel.
|
|
14
|
+
|
|
15
|
+
#pragma once
|
|
16
|
+
|
|
17
|
+
#import <AppKit/AppKit.h>
|
|
18
|
+
|
|
19
|
+
namespace basalt {
|
|
20
|
+
|
|
21
|
+
// The app's own window, or nil when it has none yet. Main thread only.
|
|
22
|
+
NSWindow *appWindow();
|
|
23
|
+
|
|
24
|
+
} // namespace basalt
|
|
@@ -23,7 +23,9 @@
|
|
|
23
23
|
#include <functional>
|
|
24
24
|
#include <string>
|
|
25
25
|
#include <unordered_map>
|
|
26
|
+
#include <vector>
|
|
26
27
|
|
|
28
|
+
#include "ImageBytes.h"
|
|
27
29
|
#include "ImageCache.h"
|
|
28
30
|
|
|
29
31
|
#include <react/io/IImageLoader.h>
|
|
@@ -49,7 +51,43 @@ class AppKitImageLoader : public facebook::react::IImageLoader {
|
|
|
49
51
|
AppKitImageLoader(AppKitImageLoader &&) = delete;
|
|
50
52
|
AppKitImageLoader &operator=(AppKitImageLoader &&) = delete;
|
|
51
53
|
|
|
52
|
-
|
|
54
|
+
// `onProgress` is optional and is `basalt::ImageProgress`: how much of an
|
|
55
|
+
// http fetch has arrived, called from the fetching thread. Only an http URI
|
|
56
|
+
// reports anything; see core/ImageBytes.h.
|
|
57
|
+
//
|
|
58
|
+
// Not marshalled to the main thread, unlike the callback above, and that is
|
|
59
|
+
// the point of it: a Fabric event emitter may be dispatched from any thread,
|
|
60
|
+
// and a progress tick that had to wait for the main queue would arrive after
|
|
61
|
+
// the image it is about.
|
|
62
|
+
void load(const std::string &uri,
|
|
63
|
+
Callback &&callback,
|
|
64
|
+
basalt::ImageProgress onProgress = {});
|
|
65
|
+
|
|
66
|
+
// The frames of an animated image, or null.
|
|
67
|
+
//
|
|
68
|
+
// Separate from `load` because being animated is a property of the file
|
|
69
|
+
// rather than of the request: `load` answers with the first frame, which is
|
|
70
|
+
// what a still <Image> draws and what an animated one starts on, and this
|
|
71
|
+
// says whether there is more. Both come out of the same decode, so asking
|
|
72
|
+
// costs nothing.
|
|
73
|
+
//
|
|
74
|
+
// The arrays are the loader's own and stay at the same addresses for as long
|
|
75
|
+
// as the URI is cached, which is what lets a view tell "the same animation
|
|
76
|
+
// again" from "a different one" when a layout-only mutation re-applies the
|
|
77
|
+
// props. The pointer is good until the next load, since an eviction can take
|
|
78
|
+
// the entry away.
|
|
79
|
+
struct Animation {
|
|
80
|
+
// CGImageRefs, one per frame, bridged into an NSArray so that
|
|
81
|
+
// RnAppKitView.h stays free of C++ -- the same reason the paragraph is
|
|
82
|
+
// handed over as an object.
|
|
83
|
+
NSArray *frames;
|
|
84
|
+
// The file's own delays in milliseconds, unclamped: the clamp is
|
|
85
|
+
// core/ImageAnimation.h's and belongs where the arithmetic is.
|
|
86
|
+
NSArray<NSNumber *> *delaysMs;
|
|
87
|
+
// Zero for forever, which is what a looping GIF carries.
|
|
88
|
+
unsigned loopCount;
|
|
89
|
+
};
|
|
90
|
+
const Animation *animation(const std::string &uri);
|
|
53
91
|
|
|
54
92
|
// --- IImageLoader ----------------------------------------------------------
|
|
55
93
|
//
|
|
@@ -70,8 +108,17 @@ class AppKitImageLoader : public facebook::react::IImageLoader {
|
|
|
70
108
|
// Nothing evicts from this yet; see docs/BACKLOG.md.
|
|
71
109
|
// Caches an image and releases whatever the policy dropped.
|
|
72
110
|
void remember(const std::string &uri, CGImageRef image);
|
|
111
|
+
void rememberAnimation(const std::string &uri,
|
|
112
|
+
const std::vector<CGImageRef> &frames,
|
|
113
|
+
const std::vector<unsigned> &delaysMs,
|
|
114
|
+
unsigned loopCount);
|
|
115
|
+
void releaseAnimation(Animation &animation);
|
|
116
|
+
void forgetAnimations();
|
|
73
117
|
|
|
74
118
|
std::unordered_map<std::string, CGImageRef> cache_;
|
|
119
|
+
// Only for the files that have more than one frame, so a still image costs
|
|
120
|
+
// nothing. Keyed by the same URI and dropped by the same eviction.
|
|
121
|
+
std::unordered_map<std::string, Animation> animations_;
|
|
75
122
|
// Which URI goes next, and when. The images are this class's; the decision
|
|
76
123
|
// is shared with the other two hosts. See core/ImageCache.h.
|
|
77
124
|
basalt::ImageCachePolicy policy_;
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
#import <ImageIO/ImageIO.h>
|
|
6
6
|
|
|
7
|
+
#include <optional>
|
|
7
8
|
#include <thread>
|
|
8
9
|
#include <utility>
|
|
10
|
+
#include <vector>
|
|
9
11
|
|
|
10
12
|
namespace basalt {
|
|
11
13
|
|
|
@@ -14,8 +16,17 @@ struct AppKitImageLoader::Pending {
|
|
|
14
16
|
AppKitImageLoader *loader;
|
|
15
17
|
std::string uri;
|
|
16
18
|
CGImageRef image;
|
|
19
|
+
// Every frame and its delay, for a file that has more than one. Decoded on
|
|
20
|
+
// the worker thread with the first frame, because a CGImage is immutable and
|
|
21
|
+
// the expensive part is the same work either way.
|
|
22
|
+
std::vector<CGImageRef> frames;
|
|
23
|
+
std::vector<unsigned> delaysMs;
|
|
24
|
+
unsigned loopCount;
|
|
17
25
|
std::string error;
|
|
18
26
|
Callback callback;
|
|
27
|
+
// Called from the worker thread as the bytes arrive, rather than from the
|
|
28
|
+
// main-queue delivery below: see the header.
|
|
29
|
+
basalt::ImageProgress onProgress;
|
|
19
30
|
};
|
|
20
31
|
|
|
21
32
|
AppKitImageLoader::AppKitImageLoader() = default;
|
|
@@ -26,6 +37,29 @@ AppKitImageLoader::~AppKitImageLoader() {
|
|
|
26
37
|
CGImageRelease(image);
|
|
27
38
|
}
|
|
28
39
|
cache_.clear();
|
|
40
|
+
forgetAnimations();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Releases every frame of every animation.
|
|
44
|
+
//
|
|
45
|
+
// Each frame carries two references: the one the decode produced, which this
|
|
46
|
+
// class owns, and the one the NSArray took when the frame went in. So both have
|
|
47
|
+
// to go: the `CGImageRelease` drops the decode's, and nilling the array lets
|
|
48
|
+
// ARC drop the array's.
|
|
49
|
+
void AppKitImageLoader::forgetAnimations() {
|
|
50
|
+
for (auto &[uri, animation] : animations_) {
|
|
51
|
+
(void)uri;
|
|
52
|
+
releaseAnimation(animation);
|
|
53
|
+
}
|
|
54
|
+
animations_.clear();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
void AppKitImageLoader::releaseAnimation(Animation &animation) {
|
|
58
|
+
for (id frame in animation.frames) {
|
|
59
|
+
CGImageRelease((__bridge CGImageRef)frame);
|
|
60
|
+
}
|
|
61
|
+
animation.frames = nil;
|
|
62
|
+
animation.delaysMs = nil;
|
|
29
63
|
}
|
|
30
64
|
|
|
31
65
|
namespace {
|
|
@@ -35,7 +69,96 @@ namespace {
|
|
|
35
69
|
// means telling it a size and a context -- so a 160x100 PNG can come back
|
|
36
70
|
// 320x200 on a Retina display, which then measures wrong. CGImageSource hands
|
|
37
71
|
// back exactly what is in the file.
|
|
38
|
-
|
|
72
|
+
// One frame's delay in milliseconds, or nothing when the format carries none.
|
|
73
|
+
//
|
|
74
|
+
// Three dictionaries for three formats, and the unclamped delay first where
|
|
75
|
+
// there is one: ImageIO's clamped value has already had a floor of its own
|
|
76
|
+
// applied, and the clamp belongs in core/ImageAnimation.h where both hosts and
|
|
77
|
+
// the next one can see it.
|
|
78
|
+
std::optional<unsigned> delayOfFrame(CGImageSourceRef source, size_t index) {
|
|
79
|
+
CFDictionaryRef properties = CGImageSourceCopyPropertiesAtIndex(source, index, nullptr);
|
|
80
|
+
if (properties == nullptr) {
|
|
81
|
+
return std::nullopt;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
std::optional<unsigned> milliseconds;
|
|
85
|
+
for (CFStringRef container : {kCGImagePropertyGIFDictionary,
|
|
86
|
+
kCGImagePropertyPNGDictionary,
|
|
87
|
+
kCGImagePropertyWebPDictionary}) {
|
|
88
|
+
const void *found = CFDictionaryGetValue(properties, container);
|
|
89
|
+
if (found == nullptr) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
CFDictionaryRef dictionary = static_cast<CFDictionaryRef>(found);
|
|
93
|
+
for (CFStringRef key : {kCGImagePropertyGIFUnclampedDelayTime,
|
|
94
|
+
kCGImagePropertyGIFDelayTime,
|
|
95
|
+
kCGImagePropertyAPNGUnclampedDelayTime,
|
|
96
|
+
kCGImagePropertyAPNGDelayTime,
|
|
97
|
+
kCGImagePropertyWebPUnclampedDelayTime,
|
|
98
|
+
kCGImagePropertyWebPDelayTime}) {
|
|
99
|
+
const void *value = CFDictionaryGetValue(dictionary, key);
|
|
100
|
+
if (value == nullptr) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
double seconds = 0.0;
|
|
104
|
+
if (CFNumberGetValue(static_cast<CFNumberRef>(value), kCFNumberDoubleType, &seconds)) {
|
|
105
|
+
milliseconds = static_cast<unsigned>(seconds * 1000.0 + 0.5);
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (milliseconds.has_value()) {
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
CFRelease(properties);
|
|
115
|
+
return milliseconds;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// How many times round, zero for forever.
|
|
119
|
+
//
|
|
120
|
+
// There is no "absent" case to handle, which was measured rather than assumed:
|
|
121
|
+
// ImageIO answers `kCGImagePropertyGIFLoopCount` with 1 for a GIF carrying no
|
|
122
|
+
// NETSCAPE2.0 extension at all, which is the format's way of saying play once.
|
|
123
|
+
// So the fallback below is unreachable for a GIF and left as forever for a
|
|
124
|
+
// format that really says nothing.
|
|
125
|
+
unsigned loopCountOf(CGImageSourceRef source) {
|
|
126
|
+
CFDictionaryRef properties = CGImageSourceCopyProperties(source, nullptr);
|
|
127
|
+
if (properties == nullptr) {
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
unsigned loops = 0;
|
|
132
|
+
for (auto [container, key] :
|
|
133
|
+
{std::pair<CFStringRef, CFStringRef>{kCGImagePropertyGIFDictionary,
|
|
134
|
+
kCGImagePropertyGIFLoopCount},
|
|
135
|
+
std::pair<CFStringRef, CFStringRef>{kCGImagePropertyPNGDictionary,
|
|
136
|
+
kCGImagePropertyAPNGLoopCount},
|
|
137
|
+
std::pair<CFStringRef, CFStringRef>{kCGImagePropertyWebPDictionary,
|
|
138
|
+
kCGImagePropertyWebPLoopCount}}) {
|
|
139
|
+
const void *found = CFDictionaryGetValue(properties, container);
|
|
140
|
+
if (found == nullptr) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const void *value = CFDictionaryGetValue(static_cast<CFDictionaryRef>(found), key);
|
|
144
|
+
int count = 0;
|
|
145
|
+
if (value != nullptr &&
|
|
146
|
+
CFNumberGetValue(static_cast<CFNumberRef>(value), kCFNumberIntType, &count) &&
|
|
147
|
+
count > 0) {
|
|
148
|
+
loops = static_cast<unsigned>(count);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
CFRelease(properties);
|
|
154
|
+
return loops;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
CGImageRef decode(const std::string &bytes,
|
|
158
|
+
std::string *error,
|
|
159
|
+
std::vector<CGImageRef> *frames = nullptr,
|
|
160
|
+
std::vector<unsigned> *delaysMs = nullptr,
|
|
161
|
+
unsigned *loopCount = nullptr) {
|
|
39
162
|
if (bytes.empty()) {
|
|
40
163
|
*error = "no image data";
|
|
41
164
|
return nullptr;
|
|
@@ -56,15 +179,51 @@ CGImageRef decode(const std::string &bytes, std::string *error) {
|
|
|
56
179
|
return nullptr;
|
|
57
180
|
}
|
|
58
181
|
|
|
59
|
-
// Index 0: an animated GIF and a multi-page TIFF both have more, and drawing
|
|
60
|
-
// the first frame is the honest thing to do until there is an animator. See
|
|
61
|
-
// docs/BACKLOG.md.
|
|
62
182
|
CGImageRef image = CGImageSourceCreateImageAtIndex(source, 0, nullptr);
|
|
63
|
-
CFRelease(source);
|
|
64
183
|
if (image == nullptr) {
|
|
184
|
+
CFRelease(source);
|
|
65
185
|
*error = "could not decode image";
|
|
66
186
|
return nullptr;
|
|
67
187
|
}
|
|
188
|
+
|
|
189
|
+
// The rest of the frames, when there are any. A multi-page TIFF reaches here
|
|
190
|
+
// too and is deliberately not animated: it carries no per-frame delay, so
|
|
191
|
+
// `delayOfFrame` answers nothing, the frames are dropped, and the caller sees
|
|
192
|
+
// the still first page -- which is what a multi-page TIFF is.
|
|
193
|
+
if (frames != nullptr && delaysMs != nullptr && loopCount != nullptr) {
|
|
194
|
+
const size_t count = CGImageSourceGetCount(source);
|
|
195
|
+
if (count > 1) {
|
|
196
|
+
std::vector<unsigned> delays;
|
|
197
|
+
std::vector<CGImageRef> decoded;
|
|
198
|
+
bool everyFrameHasADelay = true;
|
|
199
|
+
for (size_t index = 0; index < count; index++) {
|
|
200
|
+
const std::optional<unsigned> delay = delayOfFrame(source, index);
|
|
201
|
+
if (!delay.has_value()) {
|
|
202
|
+
everyFrameHasADelay = false;
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
CGImageRef frame = index == 0 ? CGImageRetain(image)
|
|
206
|
+
: CGImageSourceCreateImageAtIndex(source, index, nullptr);
|
|
207
|
+
if (frame == nullptr) {
|
|
208
|
+
everyFrameHasADelay = false;
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
decoded.push_back(frame);
|
|
212
|
+
delays.push_back(*delay);
|
|
213
|
+
}
|
|
214
|
+
if (everyFrameHasADelay) {
|
|
215
|
+
*frames = std::move(decoded);
|
|
216
|
+
*delaysMs = std::move(delays);
|
|
217
|
+
*loopCount = loopCountOf(source);
|
|
218
|
+
} else {
|
|
219
|
+
for (CGImageRef frame : decoded) {
|
|
220
|
+
CGImageRelease(frame);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
CFRelease(source);
|
|
68
227
|
return image;
|
|
69
228
|
}
|
|
70
229
|
|
|
@@ -79,7 +238,18 @@ void AppKitImageLoader::remember(const std::string &uri, CGImageRef image) {
|
|
|
79
238
|
// Height times the row stride, which is what the decode actually allocated
|
|
80
239
|
// -- rows are padded, so width times four is an underestimate on some
|
|
81
240
|
// widths and this is not.
|
|
82
|
-
|
|
241
|
+
size_t bytes = CGImageGetBytesPerRow(image) * CGImageGetHeight(image);
|
|
242
|
+
// Every frame of an animation, because every frame is decoded and held: a
|
|
243
|
+
// sixty-frame GIF is sixty images and accounting for one of them would let
|
|
244
|
+
// the cache hold far more than it was told to.
|
|
245
|
+
if (const auto found = animations_.find(uri); found != animations_.end()) {
|
|
246
|
+
for (id frame in found->second.frames) {
|
|
247
|
+
CGImageRef one = (__bridge CGImageRef)frame;
|
|
248
|
+
if (one != image) {
|
|
249
|
+
bytes += CGImageGetBytesPerRow(one) * CGImageGetHeight(one);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
83
253
|
|
|
84
254
|
for (const std::string &evicted : policy_.insert(uri, bytes)) {
|
|
85
255
|
const auto it = cache_.find(evicted);
|
|
@@ -87,10 +257,16 @@ void AppKitImageLoader::remember(const std::string &uri, CGImageRef image) {
|
|
|
87
257
|
CGImageRelease(it->second);
|
|
88
258
|
cache_.erase(it);
|
|
89
259
|
}
|
|
260
|
+
if (const auto animated = animations_.find(evicted); animated != animations_.end()) {
|
|
261
|
+
releaseAnimation(animated->second);
|
|
262
|
+
animations_.erase(animated);
|
|
263
|
+
}
|
|
90
264
|
}
|
|
91
265
|
}
|
|
92
266
|
|
|
93
|
-
void AppKitImageLoader::load(const std::string &uri,
|
|
267
|
+
void AppKitImageLoader::load(const std::string &uri,
|
|
268
|
+
Callback &&callback,
|
|
269
|
+
basalt::ImageProgress onProgress) {
|
|
94
270
|
if (uri.empty()) {
|
|
95
271
|
callback(nullptr, "empty source uri");
|
|
96
272
|
return;
|
|
@@ -102,20 +278,28 @@ void AppKitImageLoader::load(const std::string &uri, Callback &&callback) {
|
|
|
102
278
|
return;
|
|
103
279
|
}
|
|
104
280
|
|
|
105
|
-
auto *pending = new Pending{this,
|
|
281
|
+
auto *pending = new Pending{this, uri, nullptr, {}, {}, 0U, {},
|
|
282
|
+
std::move(callback), std::move(onProgress)};
|
|
106
283
|
|
|
107
284
|
std::thread([pending]() {
|
|
108
285
|
std::string bytes;
|
|
109
286
|
// Both the fetch and the decode run here. A CGImage is immutable and
|
|
110
287
|
// thread-safe, so unlike the GTK side there is nothing that has to wait for
|
|
111
288
|
// the main thread except the delivery itself.
|
|
112
|
-
if (fetchImageBytes(pending->uri, &bytes, &pending->error)) {
|
|
113
|
-
pending->image = decode(bytes, &pending->error
|
|
289
|
+
if (fetchImageBytes(pending->uri, &bytes, &pending->error, pending->onProgress)) {
|
|
290
|
+
pending->image = decode(bytes, &pending->error, &pending->frames, &pending->delaysMs,
|
|
291
|
+
&pending->loopCount);
|
|
114
292
|
}
|
|
115
293
|
|
|
116
294
|
dispatch_async_f(dispatch_get_main_queue(), pending, [](void *data) {
|
|
117
295
|
std::unique_ptr<Pending> done{static_cast<Pending *>(data)};
|
|
118
296
|
if (done->image != nullptr) {
|
|
297
|
+
// The frames first, so that `remember` can account for all of them in
|
|
298
|
+
// one insert rather than growing the cache's idea of this URI twice.
|
|
299
|
+
if (done->frames.size() > 1) {
|
|
300
|
+
done->loader->rememberAnimation(done->uri, done->frames, done->delaysMs,
|
|
301
|
+
done->loopCount);
|
|
302
|
+
}
|
|
119
303
|
// The cache takes the reference the decode produced; the callback
|
|
120
304
|
// borrows it, and a view that keeps the image retains its own.
|
|
121
305
|
done->loader->remember(done->uri, done->image);
|
|
@@ -144,6 +328,40 @@ void AppKitImageLoader::loadImage(const std::string &uri,
|
|
|
144
328
|
});
|
|
145
329
|
}
|
|
146
330
|
|
|
331
|
+
// Keeps the frames as NSArrays, built once here. The arrays are what the view
|
|
332
|
+
// is handed, and handing back the same arrays each time is what lets it tell a
|
|
333
|
+
// re-mount from a new animation; see RnAppKitView's setRnImageFrames.
|
|
334
|
+
void AppKitImageLoader::rememberAnimation(const std::string &uri,
|
|
335
|
+
const std::vector<CGImageRef> &frames,
|
|
336
|
+
const std::vector<unsigned> &delaysMs,
|
|
337
|
+
unsigned loopCount) {
|
|
338
|
+
if (const auto found = animations_.find(uri); found != animations_.end()) {
|
|
339
|
+
// A second decode of the same URI, which a cache miss on one and a hit on
|
|
340
|
+
// the other can produce. The frames already held are the ones views are
|
|
341
|
+
// drawing, so these are dropped rather than swapped in.
|
|
342
|
+
for (CGImageRef frame : frames) {
|
|
343
|
+
CGImageRelease(frame);
|
|
344
|
+
}
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
NSMutableArray *images = [NSMutableArray arrayWithCapacity:frames.size()];
|
|
349
|
+
NSMutableArray<NSNumber *> *delays = [NSMutableArray arrayWithCapacity:delaysMs.size()];
|
|
350
|
+
for (size_t index = 0; index < frames.size(); index++) {
|
|
351
|
+
// The array's own retain, and the decode's reference is what this class
|
|
352
|
+
// releases on eviction.
|
|
353
|
+
[images addObject:(__bridge id)frames[index]];
|
|
354
|
+
[delays addObject:@(delaysMs[index])];
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
animations_[uri] = Animation{images, delays, loopCount};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const AppKitImageLoader::Animation *AppKitImageLoader::animation(const std::string &uri) {
|
|
361
|
+
const auto found = animations_.find(uri);
|
|
362
|
+
return found == animations_.end() ? nullptr : &found->second;
|
|
363
|
+
}
|
|
364
|
+
|
|
147
365
|
facebook::react::IImageLoader::CacheStatus AppKitImageLoader::getCacheStatus(
|
|
148
366
|
const std::string &uri) {
|
|
149
367
|
// Memory or nothing: there is no disk cache here, and saying `Disk` would be
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
// would deliver it to the wrong object and do nothing.
|
|
23
23
|
|
|
24
24
|
#include "MenuModel.h"
|
|
25
|
+
#include "TextSelection.h"
|
|
26
|
+
|
|
27
|
+
#import "AppKitAppWindow.h"
|
|
25
28
|
|
|
26
29
|
#import <Cocoa/Cocoa.h>
|
|
27
30
|
|
|
@@ -113,6 +116,14 @@ SEL selectorForRole(const std::string &role) {
|
|
|
113
116
|
return nullptr;
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
// --- Roles, for a popup menu too ----------------------------------------------
|
|
120
|
+
//
|
|
121
|
+
// `showMenu` in PlatformServicesAppKit.mm performs a role itself rather than
|
|
122
|
+
// handing the item a `nil` target the way the bar does. The bar can let AppKit
|
|
123
|
+
// route the action, because nothing is waiting on an answer; a popup resolves a
|
|
124
|
+
// promise with the index chosen, so it has to stay in the conversation. It calls
|
|
125
|
+
// these two, declared in core/PlatformServices.h.
|
|
126
|
+
|
|
116
127
|
// The shortcut a role brings with it. macOS has a standard one for each, and an
|
|
117
128
|
// app that spelled its own would be fighting the platform.
|
|
118
129
|
NSString *keyEquivalentForRole(const std::string &role, NSEventModifierFlags *mask) {
|
|
@@ -328,6 +339,53 @@ MenuModel defaultMenu() {
|
|
|
328
339
|
|
|
329
340
|
} // namespace
|
|
330
341
|
|
|
342
|
+
// Every role in kMenuRoles, which is the whole list: each one is an NSResponder
|
|
343
|
+
// or NSApplication action that macOS has implemented since before this project.
|
|
344
|
+
bool menuRoleSupported(const std::string &role) {
|
|
345
|
+
return selectorForRole(role) != nullptr;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
void performMenuRole(const std::string &role) {
|
|
349
|
+
// A paragraph's selection first, when there is one: `Copy` over selected text
|
|
350
|
+
// means that text, and the responder chain below is what the role meant when
|
|
351
|
+
// only a <TextInput> could hold a selection. At most one of the two exists --
|
|
352
|
+
// pressing in a field clears a paragraph's selection on the way past -- so
|
|
353
|
+
// the order settles a case that does not arise rather than choosing between
|
|
354
|
+
// two live selections.
|
|
355
|
+
//
|
|
356
|
+
// This is also what makes Cmd+C work for a selected paragraph: the
|
|
357
|
+
// application menu's Copy item carries the key equivalent and performs this
|
|
358
|
+
// role, so there is no second key path to write. See core/TextSelection.h.
|
|
359
|
+
if (role == "copy" && copySelectedText()) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
SEL selector = selectorForRole(role);
|
|
364
|
+
if (selector == nullptr) {
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The app's own window first, and this is not belt and braces: `sendAction:`
|
|
369
|
+
// with `to:nil` walks the responder chain from the **key window's** first
|
|
370
|
+
// responder, and an app that is not the active one has no key window. So a
|
|
371
|
+
// `paste` role reached nothing at all whenever the host was launched behind
|
|
372
|
+
// something else, which is most of the time under the harness. The scenario
|
|
373
|
+
// "a role in a context menu performs it" found it by failing on a developer
|
|
374
|
+
// Mac while every CI shard passed, CI's runner having nothing else on screen.
|
|
375
|
+
//
|
|
376
|
+
// `tryToPerform:with:` walks from the responder it is sent to, which is the
|
|
377
|
+
// same walk with a different starting point: the field that has focus in the
|
|
378
|
+
// window this process owns.
|
|
379
|
+
NSWindow *const target = appWindow();
|
|
380
|
+
if (target != nil && [target.firstResponder tryToPerform:selector with:nil]) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// And then the usual routing, which is what answers for a role no responder
|
|
385
|
+
// wants: `terminate:` belongs to the application and not to a field.
|
|
386
|
+
[NSApp sendAction:selector to:nil from:nil];
|
|
387
|
+
}
|
|
388
|
+
|
|
331
389
|
bool applicationMenuSupported() {
|
|
332
390
|
return true;
|
|
333
391
|
}
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
#import "AppKitScrollView.h"
|
|
18
18
|
#import "AppKitTextInput.h"
|
|
19
19
|
|
|
20
|
+
#include "LabelRegistry.h"
|
|
21
|
+
#include "LiveRegions.h"
|
|
20
22
|
#include "MountingWalk.h"
|
|
21
23
|
#include "RnAppKitView.h"
|
|
22
24
|
|
|
@@ -57,6 +59,13 @@ class AppKitMountingManager final : public facebook::react::IMountingManager,
|
|
|
57
59
|
// object is in reach, so this passes it on. See core/UIManagerAccess.h.
|
|
58
60
|
void setUIManager(std::weak_ptr<facebook::react::UIManager> uiManager) noexcept override;
|
|
59
61
|
|
|
62
|
+
// React Native hands this a null executor from `destroyReactInstance`, which
|
|
63
|
+
// is the only notice a mounting manager gets that the instance is going
|
|
64
|
+
// away -- a reload destroys one and builds another while this object
|
|
65
|
+
// survives. See MountingWalk::invalidatePendingMounts.
|
|
66
|
+
void setSchedulerTaskExecutor(
|
|
67
|
+
facebook::react::SchedulerTaskExecutor &&schedulerTaskExecutor) noexcept override;
|
|
68
|
+
|
|
60
69
|
// --- Host-facing ----------------------------------------------------------
|
|
61
70
|
//
|
|
62
71
|
// createSurfaceRoot, destroySurfaceRoot, getSurfaceRoot, viewForTag and
|
|
@@ -114,6 +123,17 @@ class AppKitMountingManager final : public facebook::react::IMountingManager,
|
|
|
114
123
|
void applyTextInput(RnAppKitView *view, const facebook::react::ShadowView &shadowView);
|
|
115
124
|
void applyLayoutMetrics(RnAppKitView *view, const facebook::react::ShadowView &shadowView);
|
|
116
125
|
|
|
126
|
+
// The labelled-by relations whose resolution changed in the transaction that
|
|
127
|
+
// just mounted. Once per transaction rather than per view, because a view can
|
|
128
|
+
// name a label that mounts after it.
|
|
129
|
+
void applyLabelRelations();
|
|
130
|
+
|
|
131
|
+
// Announces any live region whose text changed in the transaction that just
|
|
132
|
+
// mounted. Once per transaction, and after it, because the text to announce is
|
|
133
|
+
// what the region says *now*: a <Text> inside it may have been updated by a
|
|
134
|
+
// later mutation in the same batch.
|
|
135
|
+
void announceLiveRegions();
|
|
136
|
+
|
|
117
137
|
AppKitScrollViewManager scrollViews_;
|
|
118
138
|
AppKitTextInputManager textInputs_;
|
|
119
139
|
// Shared rather than held by value: React Native's `ImageLoaderModule` takes
|
|
@@ -130,6 +150,20 @@ class AppKitMountingManager final : public facebook::react::IMountingManager,
|
|
|
130
150
|
// The source each <Image> is currently showing, so that a mutation which
|
|
131
151
|
// changed only layout does not restart the load.
|
|
132
152
|
std::unordered_map<facebook::react::Tag, std::string> imageUris_;
|
|
153
|
+
|
|
154
|
+
// Which view each `nativeID` names, and which views are waiting to be labelled
|
|
155
|
+
// by one. Shared with the GTK host; see core/LabelRegistry.h for why the
|
|
156
|
+
// resolution cannot happen as the props arrive.
|
|
157
|
+
basalt::LabelRegistry labels_;
|
|
158
|
+
|
|
159
|
+
// Which views are live regions and what each last said, so a changed status
|
|
160
|
+
// message can be announced. Shared with the GTK host; see core/LiveRegions.h.
|
|
161
|
+
basalt::LiveRegionRegistry liveRegions_;
|
|
162
|
+
|
|
163
|
+
// Each live region's `accessibilityLabel`, which stands in for its text when
|
|
164
|
+
// the app set one. Kept beside the registry so both hosts decide it the same
|
|
165
|
+
// way and in the same order.
|
|
166
|
+
std::unordered_map<facebook::react::Tag, std::string> liveRegionLabels_;
|
|
133
167
|
};
|
|
134
168
|
|
|
135
169
|
} // namespace basalt
|