basalt-appkit 0.0.0-rc5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/cli/runMacos.js +33 -0
  3. package/cli/shared.js +75 -0
  4. package/native/CMakeLists.txt +298 -0
  5. package/native/appkit/AppKitAnimationChoreographer.h +54 -0
  6. package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
  7. package/native/appkit/AppKitDropTarget.h +34 -0
  8. package/native/appkit/AppKitDropTarget.mm +292 -0
  9. package/native/appkit/AppKitFocus.h +120 -0
  10. package/native/appkit/AppKitFocus.mm +293 -0
  11. package/native/appkit/AppKitImageLoader.h +80 -0
  12. package/native/appkit/AppKitImageLoader.mm +156 -0
  13. package/native/appkit/AppKitKeyEvents.h +26 -0
  14. package/native/appkit/AppKitKeyEvents.mm +97 -0
  15. package/native/appkit/AppKitMenuBar.mm +424 -0
  16. package/native/appkit/AppKitMountingManager.h +135 -0
  17. package/native/appkit/AppKitMountingManager.mm +946 -0
  18. package/native/appkit/AppKitRunLoopObserver.h +34 -0
  19. package/native/appkit/AppKitRunLoopObserver.mm +42 -0
  20. package/native/appkit/AppKitScrollView.h +168 -0
  21. package/native/appkit/AppKitScrollView.mm +550 -0
  22. package/native/appkit/AppKitSkiaContext.h +33 -0
  23. package/native/appkit/AppKitSkiaContext.mm +255 -0
  24. package/native/appkit/AppKitSkiaModule.h +53 -0
  25. package/native/appkit/AppKitSkiaModule.mm +81 -0
  26. package/native/appkit/AppKitSkiaPeer.h +37 -0
  27. package/native/appkit/AppKitSkiaPeer.mm +123 -0
  28. package/native/appkit/AppKitSkiaView.h +62 -0
  29. package/native/appkit/AppKitSnapshot.h +21 -0
  30. package/native/appkit/AppKitSnapshot.mm +91 -0
  31. package/native/appkit/AppKitTextInput.h +140 -0
  32. package/native/appkit/AppKitTextInput.mm +588 -0
  33. package/native/appkit/AppKitTextPeer.h +74 -0
  34. package/native/appkit/AppKitTextPeer.mm +353 -0
  35. package/native/appkit/AppKitTitleBar.h +90 -0
  36. package/native/appkit/AppKitTitleBar.mm +186 -0
  37. package/native/appkit/AppKitTouchDispatcher.h +138 -0
  38. package/native/appkit/AppKitTouchDispatcher.mm +514 -0
  39. package/native/appkit/AppKitWindowControl.mm +222 -0
  40. package/native/appkit/AppKitWindowModule.h +62 -0
  41. package/native/appkit/AppKitWindowModule.mm +194 -0
  42. package/native/appkit/ColorSchemeAppKit.mm +71 -0
  43. package/native/appkit/ComponentRegistryAppKit.mm +121 -0
  44. package/native/appkit/CoreTextLayout.h +43 -0
  45. package/native/appkit/CoreTextLayout.mm +252 -0
  46. package/native/appkit/CoreTextLayoutManager.mm +138 -0
  47. package/native/appkit/FontRegistryCoreText.mm +148 -0
  48. package/native/appkit/PlatformServicesAppKit.mm +540 -0
  49. package/native/appkit/RnAppKitView.h +417 -0
  50. package/native/appkit/RnAppKitView.mm +1605 -0
  51. package/native/appkit/RnTextLayout.h +38 -0
  52. package/native/appkit/RnTextLayout.mm +168 -0
  53. package/native/appkit/demo_layout_appkit.mm +93 -0
  54. package/native/appkit/main_appkit.mm +1860 -0
  55. package/native/appkit/mount_harness_appkit.mm +197 -0
  56. package/native/tests/test_appkit_accessibility.mm +170 -0
  57. package/native/tests/test_appkit_image.mm +237 -0
  58. package/native/tests/test_appkit_input.mm +852 -0
  59. package/native/tests/test_appkit_key_events.mm +150 -0
  60. package/native/tests/test_appkit_mounting.mm +430 -0
  61. package/native/tests/test_appkit_scrollbar.mm +132 -0
  62. package/native/tests/test_appkit_services.mm +57 -0
  63. package/native/tests/test_appkit_skia.mm +212 -0
  64. package/native/tests/test_appkit_text.mm +180 -0
  65. package/native/tests/test_appkit_textinput.mm +748 -0
  66. package/native/tests/test_appkit_titlebar.mm +107 -0
  67. package/native/tests/test_appkit_view.mm +237 -0
  68. package/package.json +26 -0
  69. package/react-native.config.js +22 -0
@@ -0,0 +1,43 @@
1
+ // Turning React Native's AttributedString into something Core Text can lay out.
2
+ //
3
+ // The counterpart of basalt-gtk's PangoTextLayout, and it exists
4
+ // for the same reason: this is the one place that knows both halves, and it is
5
+ // deliberately used by both sides of the text problem.
6
+ //
7
+ // - `TextLayoutManager::measure` (CoreTextLayoutManager.mm) builds a layout to
8
+ // ask Core Text how big the text is, so Yoga can lay it out.
9
+ // - `AppKitMountingManager` builds the same layout and hands it to the view,
10
+ // which draws it.
11
+ //
12
+ // Measuring and painting must agree, so they must not build layouts
13
+ // differently. Sharing this is what guarantees that.
14
+ //
15
+ // Unlike the Pango side, nothing here needs a mutex. Core Text is documented as
16
+ // thread-safe, and Fabric measures on its layout thread while AppKit draws on
17
+ // the main one. Pango's font map is not, which is why that file serialises
18
+ // every call and this one does not.
19
+ //
20
+ // The paragraph object itself is in RnTextLayout.h, which has no React Native in
21
+ // it, so the view layer can draw one without depending on React Native.
22
+
23
+ #pragma once
24
+
25
+ #import "RnTextLayout.h"
26
+
27
+ #include <react/renderer/attributedstring/AttributedString.h>
28
+ #include <react/renderer/attributedstring/ParagraphAttributes.h>
29
+ #include <react/renderer/attributedstring/TextAttributes.h>
30
+
31
+ namespace basalt {
32
+
33
+ // Builds a layout for `attributedString`.
34
+ RnTextLayout *buildTextLayout(const facebook::react::AttributedString &attributedString,
35
+ const facebook::react::ParagraphAttributes &paragraphAttributes);
36
+
37
+ // The attributes for a whole string, for anything that holds its own text
38
+ // rather than a layout built here -- the equivalent of Pango's
39
+ // buildTextAttributes, and what a <TextInput> will need.
40
+ NSDictionary<NSAttributedStringKey, id> *buildTextAttributes(
41
+ const facebook::react::TextAttributes &textAttributes);
42
+
43
+ } // namespace basalt
@@ -0,0 +1,252 @@
1
+ #import "CoreTextLayout.h"
2
+
3
+ #include "FontRegistry.h"
4
+
5
+ #include <react/renderer/graphics/Color.h>
6
+
7
+ #include <cmath>
8
+ #include <string>
9
+
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // AttributedString -> NSAttributedString
13
+ // ---------------------------------------------------------------------------
14
+
15
+ namespace basalt {
16
+
17
+ using facebook::react::AttributedString;
18
+ using facebook::react::EllipsizeMode;
19
+ using facebook::react::FontStyle;
20
+ using facebook::react::FontWeight;
21
+ using facebook::react::ParagraphAttributes;
22
+ using facebook::react::TextAlignment;
23
+ using facebook::react::TextAttributes;
24
+ using facebook::react::TextDecorationLineType;
25
+
26
+ namespace {
27
+
28
+ // React Native's default when a <Text> sets no fontSize.
29
+ constexpr float kDefaultFontSize = 14.0F;
30
+
31
+ bool isSet(facebook::react::Float value) {
32
+ return !std::isnan(value);
33
+ }
34
+
35
+ // CSS numeric weights to Core Text's -1..1 scale, using Apple's own constants
36
+ // for the named steps. A straight linear mapping would put "bold" somewhere
37
+ // between semibold and heavy and make every bold face on the system slightly
38
+ // wrong.
39
+ CGFloat toCoreTextWeight(FontWeight weight) {
40
+ switch (static_cast<int>(weight)) {
41
+ case 100: return NSFontWeightUltraLight;
42
+ case 200: return NSFontWeightThin;
43
+ case 300: return NSFontWeightLight;
44
+ case 400: return NSFontWeightRegular;
45
+ case 500: return NSFontWeightMedium;
46
+ case 600: return NSFontWeightSemibold;
47
+ case 700: return NSFontWeightBold;
48
+ case 800: return NSFontWeightHeavy;
49
+ case 900: return NSFontWeightBlack;
50
+ default: break;
51
+ }
52
+ return NSFontWeightRegular;
53
+ }
54
+
55
+ NSTextAlignment toTextAlignment(TextAlignment alignment) {
56
+ switch (alignment) {
57
+ case TextAlignment::Center:
58
+ return NSTextAlignmentCenter;
59
+ case TextAlignment::Right:
60
+ #if BASALT_RN_MINOR >= 87
61
+ case TextAlignment::End:
62
+ #endif
63
+ return NSTextAlignmentRight;
64
+ case TextAlignment::Justified:
65
+ return NSTextAlignmentJustified;
66
+ case TextAlignment::Left:
67
+ #if BASALT_RN_MINOR >= 87
68
+ case TextAlignment::Start:
69
+ #endif
70
+ case TextAlignment::Natural:
71
+ break;
72
+ }
73
+ return NSTextAlignmentNatural;
74
+ }
75
+
76
+ NSColor *toColor(const facebook::react::SharedColor &color) {
77
+ const auto components = facebook::react::colorComponentsFromColor(color);
78
+ // sRGB explicitly, for the same reason RnAppKitView builds its background
79
+ // that way: a device colour space shifts every colour on a wide-gamut
80
+ // display, and text that does not match Linux is the bug this project is
81
+ // least able to afford.
82
+ return [NSColor colorWithSRGBRed:components.red
83
+ green:components.green
84
+ blue:components.blue
85
+ alpha:components.alpha];
86
+ }
87
+
88
+ NSFont *fontFor(const TextAttributes &textAttributes) {
89
+ float size = isSet(textAttributes.fontSize) ? static_cast<float>(textAttributes.fontSize)
90
+ : kDefaultFontSize;
91
+ if (isSet(textAttributes.fontSizeMultiplier) && textAttributes.fontSizeMultiplier > 0) {
92
+ size *= static_cast<float>(textAttributes.fontSizeMultiplier);
93
+ }
94
+
95
+ const CGFloat weight = textAttributes.fontWeight ? toCoreTextWeight(*textAttributes.fontWeight)
96
+ : NSFontWeightRegular;
97
+ const bool italic = textAttributes.fontStyle &&
98
+ (*textAttributes.fontStyle == FontStyle::Italic ||
99
+ *textAttributes.fontStyle == FontStyle::Oblique);
100
+
101
+ NSFont *font = nil;
102
+ if (textAttributes.fontFamily.empty()) {
103
+ // The system font, which is what "no fontFamily" means on a Mac -- and
104
+ // asking for it by name would get the wrong thing, since San Francisco is
105
+ // not available under its own family name.
106
+ font = [NSFont systemFontOfSize:size weight:weight];
107
+ } else {
108
+ // Through the runtime registry: a font an app loaded is known to the app by
109
+ // a name it chose and to Core Text by the name inside the file.
110
+ // resolveFontFamily returns the argument unchanged for system families.
111
+ const std::string family = resolveFontFamily(textAttributes.fontFamily);
112
+ NSString *familyName = [NSString stringWithUTF8String:family.c_str()];
113
+
114
+ NSFontDescriptor *descriptor = [NSFontDescriptor fontDescriptorWithFontAttributes:@{
115
+ NSFontFamilyAttribute : familyName,
116
+ NSFontTraitsAttribute : @{NSFontWeightTrait : @(weight)},
117
+ }];
118
+ font = [NSFont fontWithDescriptor:descriptor size:size];
119
+ if (font == nil) {
120
+ // A family the system does not have. Falling back to the system font
121
+ // rather than to nil, because a nil font makes Core Text drop the run
122
+ // entirely and the text silently disappears.
123
+ font = [NSFont systemFontOfSize:size weight:weight];
124
+ }
125
+ }
126
+
127
+ if (italic) {
128
+ NSFontDescriptor *italicised =
129
+ [font.fontDescriptor fontDescriptorWithSymbolicTraits:NSFontDescriptorTraitItalic];
130
+ NSFont *slanted = [NSFont fontWithDescriptor:italicised size:size];
131
+ if (slanted != nil) {
132
+ font = slanted;
133
+ }
134
+ }
135
+
136
+ return font;
137
+ }
138
+
139
+ NSParagraphStyle *paragraphStyleFor(const TextAttributes &textAttributes) {
140
+ NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init];
141
+
142
+ if (textAttributes.alignment) {
143
+ style.alignment = toTextAlignment(*textAttributes.alignment);
144
+ }
145
+
146
+ // React Native's lineHeight is the total line box height, and setting both
147
+ // bounds to it is how that is said in AppKit. Leaving one of them out gives a
148
+ // minimum or a maximum, which is a different thing and only shows up on text
149
+ // whose natural height is on the other side of the value.
150
+ if (isSet(textAttributes.lineHeight)) {
151
+ const CGFloat lineHeight = static_cast<CGFloat>(textAttributes.lineHeight);
152
+ style.minimumLineHeight = lineHeight;
153
+ style.maximumLineHeight = lineHeight;
154
+ }
155
+
156
+ // Wrapping, always. The line *limit* is applied in RnTextLayout, not here:
157
+ // a truncating line break mode would make Core Text ellipsize each line it
158
+ // lays out rather than the last one kept.
159
+ style.lineBreakMode = NSLineBreakByWordWrapping;
160
+
161
+ return style;
162
+ }
163
+
164
+ } // namespace
165
+
166
+ NSDictionary<NSAttributedStringKey, id> *buildTextAttributes(const TextAttributes &textAttributes) {
167
+ NSMutableDictionary<NSAttributedStringKey, id> *attributes = [NSMutableDictionary dictionary];
168
+
169
+ attributes[NSFontAttributeName] = fontFor(textAttributes);
170
+ attributes[NSParagraphStyleAttributeName] = paragraphStyleFor(textAttributes);
171
+
172
+ if (textAttributes.foregroundColor) {
173
+ attributes[NSForegroundColorAttributeName] = toColor(textAttributes.foregroundColor);
174
+ } else {
175
+ // React Native's default is opaque black, not the system label colour --
176
+ // which is white in dark mode and would make default text invisible on a
177
+ // light background, or vice versa. Matching React Native rather than the
178
+ // platform is the right call here: the same app has to look the same on
179
+ // Linux.
180
+ attributes[NSForegroundColorAttributeName] = [NSColor colorWithSRGBRed:0 green:0 blue:0 alpha:1];
181
+ }
182
+
183
+ if (textAttributes.backgroundColor) {
184
+ attributes[NSBackgroundColorAttributeName] = toColor(textAttributes.backgroundColor);
185
+ }
186
+
187
+ if (isSet(textAttributes.letterSpacing)) {
188
+ attributes[NSKernAttributeName] = @(static_cast<CGFloat>(textAttributes.letterSpacing));
189
+ }
190
+
191
+ if (textAttributes.textDecorationLineType) {
192
+ switch (*textAttributes.textDecorationLineType) {
193
+ case TextDecorationLineType::Underline:
194
+ attributes[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle);
195
+ break;
196
+ case TextDecorationLineType::Strikethrough:
197
+ attributes[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleSingle);
198
+ break;
199
+ case TextDecorationLineType::UnderlineStrikethrough:
200
+ attributes[NSUnderlineStyleAttributeName] = @(NSUnderlineStyleSingle);
201
+ attributes[NSStrikethroughStyleAttributeName] = @(NSUnderlineStyleSingle);
202
+ break;
203
+ case TextDecorationLineType::None:
204
+ break;
205
+ }
206
+ }
207
+
208
+ return attributes;
209
+ }
210
+
211
+ RnTextLayout *buildTextLayout(const AttributedString &attributedString,
212
+ const ParagraphAttributes &paragraphAttributes) {
213
+ NSMutableAttributedString *string = [[NSMutableAttributedString alloc] init];
214
+
215
+ for (const auto &fragment : attributedString.getFragments()) {
216
+ if (fragment.string.empty()) {
217
+ continue;
218
+ }
219
+ NSString *text = [NSString stringWithUTF8String:fragment.string.c_str()];
220
+ if (text == nil) {
221
+ continue;
222
+ }
223
+ [string appendAttributedString:[[NSAttributedString alloc]
224
+ initWithString:text
225
+ attributes:buildTextAttributes(fragment.textAttributes)]];
226
+ }
227
+
228
+ CTLineTruncationType truncation = kCTLineTruncationEnd;
229
+ bool truncates = true;
230
+ switch (paragraphAttributes.ellipsizeMode) {
231
+ case EllipsizeMode::Head:
232
+ truncation = kCTLineTruncationStart;
233
+ break;
234
+ case EllipsizeMode::Middle:
235
+ truncation = kCTLineTruncationMiddle;
236
+ break;
237
+ case EllipsizeMode::Tail:
238
+ truncation = kCTLineTruncationEnd;
239
+ break;
240
+ case EllipsizeMode::Clip:
241
+ // Cut, with no ellipsis. The line limit still applies.
242
+ truncates = false;
243
+ break;
244
+ }
245
+
246
+ return [RnTextLayout layoutWithAttributedString:string
247
+ maximumNumberOfLines:paragraphAttributes.maximumNumberOfLines
248
+ truncationType:truncation
249
+ truncates:truncates ? YES : NO];
250
+ }
251
+
252
+ } // namespace basalt
@@ -0,0 +1,138 @@
1
+ // The macOS implementation of React Native's text measurement seam.
2
+ //
3
+ // `TextLayoutManager` is declared in React Native's cxx platform variant, and
4
+ // its only implementation there is a stub that ignores every attribute and
5
+ // returns `layoutConstraints.minimumSize`. cmake/ReactNativeCore.cmake drops
6
+ // that stub from the build, so a platform either provides this or fails to
7
+ // link -- which is the second of the three seams phase 19 found, and the reason
8
+ // macOS could not register a Paragraph descriptor until now.
9
+ //
10
+ // The header is unchanged and shared; only the .cpp differs, which is what
11
+ // React Native's platform/ split is for. Its Linux counterpart is
12
+ // basalt-gtk's PangoTextLayoutManager.cpp, and the two are
13
+ // deliberately the same file with a different engine inside.
14
+ //
15
+ // Yoga calls this during layout, on whichever thread is committing, so it must
16
+ // be thread-safe and it must be fast.
17
+
18
+ #import "CoreTextLayout.h"
19
+
20
+ #include "FontRegistry.h"
21
+
22
+ #include <algorithm>
23
+ #include <cmath>
24
+ #include <mutex>
25
+ #include <unordered_map>
26
+
27
+ #include <react/renderer/textlayoutmanager/TextLayoutManager.h>
28
+
29
+ namespace facebook::react {
30
+
31
+ namespace {
32
+
33
+ std::mutex &measurementCacheMutex() {
34
+ static std::mutex mutex;
35
+ return mutex;
36
+ }
37
+
38
+ // Emptied whenever a font is registered; see the note at its first use.
39
+ std::unordered_map<TextMeasureCacheKey, TextMeasurement> &measurementCache() {
40
+ static std::unordered_map<TextMeasureCacheKey, TextMeasurement> cache;
41
+ static unsigned long generation = 0;
42
+ const unsigned long current = basalt::fontGeneration();
43
+ if (current != generation) {
44
+ cache.clear();
45
+ generation = current;
46
+ }
47
+ return cache;
48
+ }
49
+
50
+ } // namespace
51
+
52
+ TextLayoutManager::TextLayoutManager(const std::shared_ptr<const ContextContainer> &contextContainer)
53
+ : contextContainer_(contextContainer), textMeasureCache_(kSimpleThreadSafeCacheSizeCap) {}
54
+
55
+ TextMeasurement TextLayoutManager::measure(const AttributedStringBox &attributedStringBox,
56
+ const ParagraphAttributes &paragraphAttributes,
57
+ const TextLayoutContext &layoutContext,
58
+ const LayoutConstraints &layoutConstraints) const {
59
+ const auto &attributedString = attributedStringBox.getValue();
60
+
61
+ // pointScaleFactor joined the cache key in React Native 0.87. On 0.81 the key
62
+ // has three fields and naming a fourth is a compile error. Its absence only
63
+ // makes the cache coarser there, and since nothing below uses the factor,
64
+ // coarser is harmless.
65
+ const TextMeasureCacheKey key{
66
+ .attributedString = attributedString,
67
+ .paragraphAttributes = paragraphAttributes,
68
+ .layoutConstraints = layoutConstraints,
69
+ #if BASALT_RN_MINOR >= 87
70
+ .pointScaleFactor = layoutContext.pointScaleFactor,
71
+ #endif
72
+ };
73
+
74
+ #if BASALT_RN_MINOR < 87
75
+ (void)layoutContext;
76
+ #endif
77
+
78
+ // Not React Native's `textMeasureCache_`, which has no way to be emptied.
79
+ //
80
+ // A font registered since a measurement invalidates it: the same string with
81
+ // the same attributes measures differently once its family exists. That is
82
+ // not expressible in the cache key, which React Native defines, so the cache
83
+ // has to be droppable instead -- and without that, a font loaded after first
84
+ // render, which is how `useFonts` and every other loader works, appears to do
85
+ // nothing at all.
86
+ {
87
+ const std::lock_guard<std::mutex> lock(measurementCacheMutex());
88
+ auto &cache = measurementCache();
89
+ const auto hit = cache.find(key);
90
+ if (hit != cache.end()) {
91
+ return hit->second;
92
+ }
93
+ }
94
+
95
+ TextMeasurement measured;
96
+ @autoreleasepool {
97
+ // An infinite maximum width means "do not wrap".
98
+ const CGFloat maxWidth = std::isinf(layoutConstraints.maximumSize.width)
99
+ ? -1.0
100
+ : static_cast<CGFloat>(layoutConstraints.maximumSize.width);
101
+
102
+ RnTextLayout *layout = basalt::buildTextLayout(attributedString, paragraphAttributes);
103
+ const CGSize size = [layout sizeForWidth:maxWidth];
104
+
105
+ // Core Text can report a line slightly wider than the width it was given,
106
+ // for an unbreakable run. Yoga treats the returned size as final, so
107
+ // clamping here keeps the paragraph inside the box its parent allotted.
108
+ const auto natural = Size{
109
+ .width = std::min(static_cast<Float>(size.width), layoutConstraints.maximumSize.width),
110
+ .height = static_cast<Float>(size.height),
111
+ };
112
+
113
+ // Inline views (`<Text><View/></Text>`) reach here as attachment fragments.
114
+ // Reporting a zero frame for each keeps the count right, which is what
115
+ // ParagraphShadowNode iterates over, but they are not positioned yet --
116
+ // the same gap the GTK side has, for the same reason.
117
+ TextMeasurement::Attachments attachments;
118
+ for (const auto &fragment : attributedString.getFragments()) {
119
+ if (fragment.isAttachment()) {
120
+ attachments.push_back(TextMeasurement::Attachment{
121
+ .frame = {.origin = {.x = 0, .y = 0}, .size = {.width = 0, .height = 0}},
122
+ .isClipped = false,
123
+ });
124
+ }
125
+ }
126
+
127
+ measured = TextMeasurement{.size = layoutConstraints.clamp(natural), .attachments = attachments};
128
+ }
129
+
130
+ {
131
+ const std::lock_guard<std::mutex> lock(measurementCacheMutex());
132
+ measurementCache().emplace(key, measured);
133
+ }
134
+
135
+ return measured;
136
+ }
137
+
138
+ } // namespace facebook::react
@@ -0,0 +1,148 @@
1
+ // The font seam on macOS, backed by Core Text.
2
+ //
3
+ // The counterpart of gtk/FontRegistryFontconfig.cpp, and the first seam phase
4
+ // 17 identified: `expo-font` hands over a file and a name of the app's choosing
5
+ // -- "Inter", say -- and expects `fontFamily: 'Inter'` to work afterwards. A
6
+ // font file carries its own family name inside it, usually something else
7
+ // entirely, and Core Text indexes by that. So loading the file is only half the
8
+ // job; the other half is remembering that this app calls it "Inter".
9
+ //
10
+ // Registration is process-scoped: the font becomes available to this process
11
+ // and to nothing else on the machine, which is what an app loading a bundled
12
+ // font wants and the only scope that needs no user consent.
13
+ //
14
+ // Nothing on this platform renders text yet -- there is no Core Text
15
+ // TextLayoutManager, which is the seam phase 19 found. So `resolveFontFamily`
16
+ // currently has no reader. It is implemented anyway, because the alternative is
17
+ // a registry that silently records nothing and a font milestone that starts by
18
+ // debugging this file.
19
+
20
+ #include "FontRegistry.h"
21
+
22
+ #import <CoreText/CoreText.h>
23
+ #import <Foundation/Foundation.h>
24
+
25
+ #include <mutex>
26
+ #include <unordered_map>
27
+ #include <vector>
28
+
29
+ namespace basalt {
30
+
31
+ namespace {
32
+
33
+ struct Registry {
34
+ std::mutex mutex;
35
+ unsigned long generation{0};
36
+ // What the app calls it -> what the file calls itself.
37
+ std::unordered_map<std::string, std::string> families;
38
+ std::vector<std::string> order;
39
+ };
40
+
41
+ Registry &registry() {
42
+ static Registry instance;
43
+ return instance;
44
+ }
45
+
46
+ // The family name recorded inside the file. Core Text will register a file and
47
+ // then index it under a name the app has never heard of, so this is read back
48
+ // rather than assumed -- the same reason the fontconfig side reads it.
49
+ //
50
+ // A file can hold several faces; the first one's family is used, which matches
51
+ // what a single-font asset means and is what fontconfig's equivalent does.
52
+ std::string familyInFile(NSURL *url) {
53
+ NSArray *descriptors =
54
+ (__bridge_transfer NSArray *)CTFontManagerCreateFontDescriptorsFromURL((__bridge CFURLRef)url);
55
+ if (descriptors.count == 0) {
56
+ return {};
57
+ }
58
+
59
+ CTFontDescriptorRef descriptor = (__bridge CTFontDescriptorRef)descriptors[0];
60
+ NSString *family = (__bridge_transfer NSString *)CTFontDescriptorCopyAttribute(
61
+ descriptor, kCTFontFamilyNameAttribute);
62
+ if (family == nil) {
63
+ return {};
64
+ }
65
+ return family.UTF8String;
66
+ }
67
+
68
+ } // namespace
69
+
70
+ bool registerFont(const std::string &name, const std::string &path) {
71
+ Registry &state = registry();
72
+ const std::lock_guard<std::mutex> lock(state.mutex);
73
+
74
+ if (state.families.find(name) != state.families.end()) {
75
+ return true;
76
+ }
77
+
78
+ @autoreleasepool {
79
+ NSURL *url = [NSURL fileURLWithPath:[NSString stringWithUTF8String:path.c_str()]];
80
+ if (url == nil) {
81
+ return false;
82
+ }
83
+
84
+ CFErrorRef error = nullptr;
85
+ if (!CTFontManagerRegisterFontsForURL((__bridge CFURLRef)url,
86
+ kCTFontManagerScopeProcess,
87
+ &error)) {
88
+ // Already registered is not a failure: two apps' worth of the same file,
89
+ // or a second call for a different app-facing name, both land here and
90
+ // both leave the font usable.
91
+ const bool alreadyRegistered =
92
+ error != nullptr && CFErrorGetCode(error) == kCTFontManagerErrorAlreadyRegistered;
93
+ if (error != nullptr) {
94
+ CFRelease(error);
95
+ }
96
+ if (!alreadyRegistered) {
97
+ return false;
98
+ }
99
+ }
100
+
101
+ const std::string family = familyInFile(url);
102
+ if (family.empty()) {
103
+ // Registered but unusable: without the real family there is no way to ask
104
+ // for it, and pretending otherwise would fail later and further away.
105
+ return false;
106
+ }
107
+
108
+ state.families.emplace(name, family);
109
+ state.order.push_back(name);
110
+ state.generation++;
111
+ }
112
+
113
+ return true;
114
+ }
115
+
116
+ unsigned long fontGeneration() {
117
+ Registry &state = registry();
118
+ const std::lock_guard<std::mutex> lock(state.mutex);
119
+ return state.generation;
120
+ }
121
+
122
+ std::string resolveFontFamily(const std::string &name) {
123
+ Registry &state = registry();
124
+ const std::lock_guard<std::mutex> lock(state.mutex);
125
+ const auto it = state.families.find(name);
126
+ return it == state.families.end() ? name : it->second;
127
+ }
128
+
129
+ bool isFontRegistered(const std::string &name) {
130
+ Registry &state = registry();
131
+ const std::lock_guard<std::mutex> lock(state.mutex);
132
+ return state.families.find(name) != state.families.end();
133
+ }
134
+
135
+ std::string registeredFontNamesJoined(char separator) {
136
+ Registry &state = registry();
137
+ const std::lock_guard<std::mutex> lock(state.mutex);
138
+ std::string joined;
139
+ for (const auto &name : state.order) {
140
+ if (!joined.empty()) {
141
+ joined.push_back(separator);
142
+ }
143
+ joined += name;
144
+ }
145
+ return joined;
146
+ }
147
+
148
+ } // namespace basalt