basalt-appkit 0.0.0-rc5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/cli/runMacos.js +33 -0
- package/cli/shared.js +75 -0
- package/native/CMakeLists.txt +298 -0
- package/native/appkit/AppKitAnimationChoreographer.h +54 -0
- package/native/appkit/AppKitAnimationChoreographer.mm +125 -0
- package/native/appkit/AppKitDropTarget.h +34 -0
- package/native/appkit/AppKitDropTarget.mm +292 -0
- package/native/appkit/AppKitFocus.h +120 -0
- package/native/appkit/AppKitFocus.mm +293 -0
- package/native/appkit/AppKitImageLoader.h +80 -0
- package/native/appkit/AppKitImageLoader.mm +156 -0
- package/native/appkit/AppKitKeyEvents.h +26 -0
- package/native/appkit/AppKitKeyEvents.mm +97 -0
- package/native/appkit/AppKitMenuBar.mm +424 -0
- package/native/appkit/AppKitMountingManager.h +135 -0
- package/native/appkit/AppKitMountingManager.mm +946 -0
- package/native/appkit/AppKitRunLoopObserver.h +34 -0
- package/native/appkit/AppKitRunLoopObserver.mm +42 -0
- package/native/appkit/AppKitScrollView.h +168 -0
- package/native/appkit/AppKitScrollView.mm +550 -0
- package/native/appkit/AppKitSkiaContext.h +33 -0
- package/native/appkit/AppKitSkiaContext.mm +255 -0
- package/native/appkit/AppKitSkiaModule.h +53 -0
- package/native/appkit/AppKitSkiaModule.mm +81 -0
- package/native/appkit/AppKitSkiaPeer.h +37 -0
- package/native/appkit/AppKitSkiaPeer.mm +123 -0
- package/native/appkit/AppKitSkiaView.h +62 -0
- package/native/appkit/AppKitSnapshot.h +21 -0
- package/native/appkit/AppKitSnapshot.mm +91 -0
- package/native/appkit/AppKitTextInput.h +140 -0
- package/native/appkit/AppKitTextInput.mm +588 -0
- package/native/appkit/AppKitTextPeer.h +74 -0
- package/native/appkit/AppKitTextPeer.mm +353 -0
- package/native/appkit/AppKitTitleBar.h +90 -0
- package/native/appkit/AppKitTitleBar.mm +186 -0
- package/native/appkit/AppKitTouchDispatcher.h +138 -0
- package/native/appkit/AppKitTouchDispatcher.mm +514 -0
- package/native/appkit/AppKitWindowControl.mm +222 -0
- package/native/appkit/AppKitWindowModule.h +62 -0
- package/native/appkit/AppKitWindowModule.mm +194 -0
- package/native/appkit/ColorSchemeAppKit.mm +71 -0
- package/native/appkit/ComponentRegistryAppKit.mm +121 -0
- package/native/appkit/CoreTextLayout.h +43 -0
- package/native/appkit/CoreTextLayout.mm +252 -0
- package/native/appkit/CoreTextLayoutManager.mm +138 -0
- package/native/appkit/FontRegistryCoreText.mm +148 -0
- package/native/appkit/PlatformServicesAppKit.mm +540 -0
- package/native/appkit/RnAppKitView.h +417 -0
- package/native/appkit/RnAppKitView.mm +1605 -0
- package/native/appkit/RnTextLayout.h +38 -0
- package/native/appkit/RnTextLayout.mm +168 -0
- package/native/appkit/demo_layout_appkit.mm +93 -0
- package/native/appkit/main_appkit.mm +1860 -0
- package/native/appkit/mount_harness_appkit.mm +197 -0
- package/native/tests/test_appkit_accessibility.mm +170 -0
- package/native/tests/test_appkit_image.mm +237 -0
- package/native/tests/test_appkit_input.mm +852 -0
- package/native/tests/test_appkit_key_events.mm +150 -0
- package/native/tests/test_appkit_mounting.mm +430 -0
- package/native/tests/test_appkit_scrollbar.mm +132 -0
- package/native/tests/test_appkit_services.mm +57 -0
- package/native/tests/test_appkit_skia.mm +212 -0
- package/native/tests/test_appkit_text.mm +180 -0
- package/native/tests/test_appkit_textinput.mm +748 -0
- package/native/tests/test_appkit_titlebar.mm +107 -0
- package/native/tests/test_appkit_view.mm +237 -0
- package/package.json +26 -0
- package/react-native.config.js +22 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#import "AppKitTextPeer.h"
|
|
2
|
+
|
|
3
|
+
#import <objc/runtime.h>
|
|
4
|
+
|
|
5
|
+
// Both peers report gaining focus the same way, and neither superclass does it
|
|
6
|
+
// for us. See the protocol's header comment for why only the gaining half.
|
|
7
|
+
@interface RnAppKitTextField : NSTextField
|
|
8
|
+
@property(nonatomic, assign) NSInteger rnTag;
|
|
9
|
+
@property(nonatomic, weak) id<RnAppKitTextPeerOwner> rnOwner;
|
|
10
|
+
@end
|
|
11
|
+
|
|
12
|
+
@implementation RnAppKitTextField
|
|
13
|
+
- (BOOL)becomeFirstResponder {
|
|
14
|
+
const BOOL became = [super becomeFirstResponder];
|
|
15
|
+
if (became) {
|
|
16
|
+
[_rnOwner rnFieldDidBecomeFirstResponder:_rnTag];
|
|
17
|
+
}
|
|
18
|
+
return became;
|
|
19
|
+
}
|
|
20
|
+
@end
|
|
21
|
+
|
|
22
|
+
@interface RnAppKitSecureTextField : NSSecureTextField
|
|
23
|
+
@property(nonatomic, assign) NSInteger rnTag;
|
|
24
|
+
@property(nonatomic, weak) id<RnAppKitTextPeerOwner> rnOwner;
|
|
25
|
+
@end
|
|
26
|
+
|
|
27
|
+
@implementation RnAppKitSecureTextField
|
|
28
|
+
- (BOOL)becomeFirstResponder {
|
|
29
|
+
const BOOL became = [super becomeFirstResponder];
|
|
30
|
+
if (became) {
|
|
31
|
+
[_rnOwner rnFieldDidBecomeFirstResponder:_rnTag];
|
|
32
|
+
}
|
|
33
|
+
return became;
|
|
34
|
+
}
|
|
35
|
+
@end
|
|
36
|
+
|
|
37
|
+
@interface RnAppKitTextView : NSTextView
|
|
38
|
+
@property(nonatomic, assign) NSInteger rnTag;
|
|
39
|
+
@property(nonatomic, weak) id<RnAppKitTextPeerOwner> rnOwner;
|
|
40
|
+
@property(nonatomic, strong, nullable) NSAttributedString *rnPlaceholder;
|
|
41
|
+
@end
|
|
42
|
+
|
|
43
|
+
@implementation RnAppKitTextView
|
|
44
|
+
- (BOOL)becomeFirstResponder {
|
|
45
|
+
const BOOL became = [super becomeFirstResponder];
|
|
46
|
+
if (became) {
|
|
47
|
+
[_rnOwner rnFieldDidBecomeFirstResponder:_rnTag];
|
|
48
|
+
}
|
|
49
|
+
return became;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// An NSTextView has no placeholder, so it is drawn. Over the top of whatever
|
|
53
|
+
// the view drew, which is nothing when the field is empty, and only when it
|
|
54
|
+
// is empty -- the one case a placeholder means anything.
|
|
55
|
+
- (void)drawRect:(NSRect)dirtyRect {
|
|
56
|
+
[super drawRect:dirtyRect];
|
|
57
|
+
if (_rnPlaceholder == nil || self.string.length > 0) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
// The same origin the first line of real text would take, so the placeholder
|
|
61
|
+
// does not shift when the user starts typing.
|
|
62
|
+
[_rnPlaceholder drawAtPoint:NSMakePoint(self.textContainerInset.width +
|
|
63
|
+
self.textContainer.lineFragmentPadding,
|
|
64
|
+
self.textContainerInset.height)];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Going from empty to one character, and back, changes whether the placeholder
|
|
68
|
+
// should be there at all -- and AppKit has no reason to know that.
|
|
69
|
+
- (void)didChangeText {
|
|
70
|
+
[super didChangeText];
|
|
71
|
+
self.needsDisplay = YES;
|
|
72
|
+
}
|
|
73
|
+
@end
|
|
74
|
+
|
|
75
|
+
NSView *RnPeerNew(BOOL multiline, BOOL secure, NSInteger tag, id owner, id delegate) {
|
|
76
|
+
if (multiline) {
|
|
77
|
+
RnAppKitTextView *view = [[RnAppKitTextView alloc] initWithFrame:NSZeroRect];
|
|
78
|
+
view.rnTag = tag;
|
|
79
|
+
view.rnOwner = owner;
|
|
80
|
+
view.delegate = (id<NSTextViewDelegate>)delegate;
|
|
81
|
+
|
|
82
|
+
// The RnAppKitView behind it draws the background, border and corner
|
|
83
|
+
// radius from React Native's style props, so the peer paints none of its
|
|
84
|
+
// own -- the same reason the single-line field is unbezeled.
|
|
85
|
+
view.drawsBackground = NO;
|
|
86
|
+
view.focusRingType = NSFocusRingTypeNone;
|
|
87
|
+
// No insets of its own: the view already places the peer inside the
|
|
88
|
+
// content inset Yoga resolved.
|
|
89
|
+
view.textContainerInset = NSZeroSize;
|
|
90
|
+
view.textContainer.lineFragmentPadding = 0;
|
|
91
|
+
// Wrap rather than run off the side. The measured box is already tall
|
|
92
|
+
// enough for the wrapped text, because the shadow node measures a
|
|
93
|
+
// multiline input against the real constraints.
|
|
94
|
+
view.textContainer.widthTracksTextView = YES;
|
|
95
|
+
view.horizontallyResizable = NO;
|
|
96
|
+
view.verticallyResizable = YES;
|
|
97
|
+
// Plain text: React Native styles the whole field through props, and rich
|
|
98
|
+
// text would let a paste bring its own font in.
|
|
99
|
+
view.richText = NO;
|
|
100
|
+
view.allowsUndo = YES;
|
|
101
|
+
return view;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
NSTextField *field;
|
|
105
|
+
if (secure) {
|
|
106
|
+
RnAppKitSecureTextField *secureField =
|
|
107
|
+
[[RnAppKitSecureTextField alloc] initWithFrame:NSZeroRect];
|
|
108
|
+
secureField.rnTag = tag;
|
|
109
|
+
secureField.rnOwner = owner;
|
|
110
|
+
field = secureField;
|
|
111
|
+
} else {
|
|
112
|
+
RnAppKitTextField *plain = [[RnAppKitTextField alloc] initWithFrame:NSZeroRect];
|
|
113
|
+
plain.rnTag = tag;
|
|
114
|
+
plain.rnOwner = owner;
|
|
115
|
+
field = plain;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// One line, and clipped rather than wrapped. An NSTextField wraps by default
|
|
119
|
+
// once its text outruns its width, which is not what a single-line
|
|
120
|
+
// <TextInput> does anywhere else -- it scrolls horizontally. Invisible until
|
|
121
|
+
// a field holds more than it can show, which is why it survived this long.
|
|
122
|
+
field.usesSingleLineMode = YES;
|
|
123
|
+
field.lineBreakMode = NSLineBreakByClipping;
|
|
124
|
+
field.cell.scrollable = YES;
|
|
125
|
+
field.cell.wraps = NO;
|
|
126
|
+
|
|
127
|
+
field.bordered = NO;
|
|
128
|
+
field.bezeled = NO;
|
|
129
|
+
field.drawsBackground = NO;
|
|
130
|
+
field.focusRingType = NSFocusRingTypeNone;
|
|
131
|
+
field.delegate = (id<NSTextFieldDelegate>)delegate;
|
|
132
|
+
// Return has to reach doCommandBySelector: rather than being swallowed as a
|
|
133
|
+
// "do nothing" action, which is what an NSTextField with no target does.
|
|
134
|
+
field.target = nil;
|
|
135
|
+
field.action = nullptr;
|
|
136
|
+
return field;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
BOOL RnPeerIsMultiline(NSView *peer) {
|
|
140
|
+
return peer != nil && [peer isKindOfClass:[NSTextView class]];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
NSString *RnPeerText(NSView *peer) {
|
|
144
|
+
if (peer == nil) {
|
|
145
|
+
return @"";
|
|
146
|
+
}
|
|
147
|
+
if (RnPeerIsMultiline(peer)) {
|
|
148
|
+
NSString *text = ((NSTextView *)peer).string;
|
|
149
|
+
return text != nil ? text : @"";
|
|
150
|
+
}
|
|
151
|
+
NSString *value = ((NSTextField *)peer).stringValue;
|
|
152
|
+
return value != nil ? value : @"";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
void RnPeerSetText(NSView *peer, NSString *text) {
|
|
156
|
+
if (peer == nil) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
NSString *value = text != nil ? text : @"";
|
|
160
|
+
if (RnPeerIsMultiline(peer)) {
|
|
161
|
+
((NSTextView *)peer).string = value;
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
((NSTextField *)peer).stringValue = value;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
NSRange RnPeerSelection(NSView *peer) {
|
|
168
|
+
if (peer == nil) {
|
|
169
|
+
return NSMakeRange(0, 0);
|
|
170
|
+
}
|
|
171
|
+
if (RnPeerIsMultiline(peer)) {
|
|
172
|
+
// An NSTextView is its own editor, so the selection is always available --
|
|
173
|
+
// which is the one place multiline is simpler than single line here.
|
|
174
|
+
return ((NSTextView *)peer).selectedRange;
|
|
175
|
+
}
|
|
176
|
+
NSTextField *field = (NSTextField *)peer;
|
|
177
|
+
if (NSText *editor = field.currentEditor) {
|
|
178
|
+
return editor.selectedRange;
|
|
179
|
+
}
|
|
180
|
+
return NSMakeRange(field.stringValue.length, 0);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
void RnPeerSetSelection(NSView *peer, NSRange range) {
|
|
184
|
+
if (peer == nil) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (RnPeerIsMultiline(peer)) {
|
|
188
|
+
NSTextView *view = (NSTextView *)peer;
|
|
189
|
+
const NSUInteger length = view.string.length;
|
|
190
|
+
const NSUInteger location = MIN(range.location, length);
|
|
191
|
+
[view setSelectedRange:NSMakeRange(location, MIN(range.length, length - location))];
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (NSText *editor = ((NSTextField *)peer).currentEditor) {
|
|
195
|
+
editor.selectedRange = range;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
void RnPeerSetEditable(NSView *peer, BOOL editable) {
|
|
200
|
+
if (peer == nil) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (RnPeerIsMultiline(peer)) {
|
|
204
|
+
NSTextView *view = (NSTextView *)peer;
|
|
205
|
+
view.editable = editable;
|
|
206
|
+
view.selectable = YES;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
NSTextField *field = (NSTextField *)peer;
|
|
210
|
+
field.editable = editable;
|
|
211
|
+
field.selectable = YES;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
void RnPeerSetTextStyle(NSView *peer, NSFont *font, NSColor *colour, NSTextAlignment alignment) {
|
|
215
|
+
if (peer == nil) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (RnPeerIsMultiline(peer)) {
|
|
219
|
+
NSTextView *view = (NSTextView *)peer;
|
|
220
|
+
if (font != nil) {
|
|
221
|
+
view.font = font;
|
|
222
|
+
}
|
|
223
|
+
if (colour != nil) {
|
|
224
|
+
view.textColor = colour;
|
|
225
|
+
}
|
|
226
|
+
view.alignment = alignment;
|
|
227
|
+
// What typing continues in. Without it the font and colour apply to what
|
|
228
|
+
// is there and the next character reverts to the system default.
|
|
229
|
+
NSMutableDictionary *typing = [NSMutableDictionary dictionary];
|
|
230
|
+
if (font != nil) {
|
|
231
|
+
typing[NSFontAttributeName] = font;
|
|
232
|
+
}
|
|
233
|
+
if (colour != nil) {
|
|
234
|
+
typing[NSForegroundColorAttributeName] = colour;
|
|
235
|
+
}
|
|
236
|
+
view.typingAttributes = typing;
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
NSTextField *field = (NSTextField *)peer;
|
|
240
|
+
if (font != nil) {
|
|
241
|
+
field.font = font;
|
|
242
|
+
}
|
|
243
|
+
if (colour != nil) {
|
|
244
|
+
field.textColor = colour;
|
|
245
|
+
}
|
|
246
|
+
field.alignment = alignment;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
void RnPeerSetPlaceholder(NSView *peer, NSAttributedString *placeholder) {
|
|
250
|
+
if (peer == nil) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (RnPeerIsMultiline(peer)) {
|
|
254
|
+
RnAppKitTextView *view = (RnAppKitTextView *)peer;
|
|
255
|
+
view.rnPlaceholder = placeholder;
|
|
256
|
+
view.needsDisplay = YES;
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
((NSTextField *)peer).placeholderAttributedString = placeholder;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
NSAttributedString *RnPeerPlaceholder(NSView *peer) {
|
|
263
|
+
if (peer == nil) {
|
|
264
|
+
return nil;
|
|
265
|
+
}
|
|
266
|
+
if (RnPeerIsMultiline(peer)) {
|
|
267
|
+
return ((RnAppKitTextView *)peer).rnPlaceholder;
|
|
268
|
+
}
|
|
269
|
+
return ((NSTextField *)peer).placeholderAttributedString;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A formatter that refuses anything longer than the limit. NSTextField has no
|
|
273
|
+
// maximum length of its own, and a formatter is what AppKit offers instead --
|
|
274
|
+
// it is consulted on every edit, which is exactly the hook needed.
|
|
275
|
+
@interface RnAppKitLengthFormatter : NSFormatter
|
|
276
|
+
@property(nonatomic, assign) NSInteger maxLength;
|
|
277
|
+
@end
|
|
278
|
+
|
|
279
|
+
@implementation RnAppKitLengthFormatter
|
|
280
|
+
- (NSString *)stringForObjectValue:(id)object {
|
|
281
|
+
return [object isKindOfClass:[NSString class]] ? object : nil;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
- (BOOL)getObjectValue:(out id *)object
|
|
285
|
+
forString:(NSString *)string
|
|
286
|
+
errorDescription:(out NSString **)error {
|
|
287
|
+
if (object != nullptr) {
|
|
288
|
+
*object = string;
|
|
289
|
+
}
|
|
290
|
+
return YES;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
- (BOOL)isPartialStringValid:(NSString *)partial
|
|
294
|
+
newEditingString:(NSString **)newString
|
|
295
|
+
errorDescription:(NSString **)error {
|
|
296
|
+
if (_maxLength <= 0 || (NSInteger)partial.length <= _maxLength) {
|
|
297
|
+
return YES;
|
|
298
|
+
}
|
|
299
|
+
// NO with no replacement: the edit is refused and what was there stays.
|
|
300
|
+
if (newString != nullptr) {
|
|
301
|
+
*newString = nil;
|
|
302
|
+
}
|
|
303
|
+
if (error != nullptr) {
|
|
304
|
+
*error = nil;
|
|
305
|
+
}
|
|
306
|
+
return NO;
|
|
307
|
+
}
|
|
308
|
+
@end
|
|
309
|
+
|
|
310
|
+
void RnPeerSetMaxLength(NSView *peer, NSInteger maxLength) {
|
|
311
|
+
if (peer == nil) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (RnPeerIsMultiline(peer)) {
|
|
315
|
+
// Stored, and read back by the delegate through RnPeerAllowsChange. An
|
|
316
|
+
// NSTextView has no formatter.
|
|
317
|
+
objc_setAssociatedObject(peer, @selector(RnPeerSetMaxLength), @(maxLength),
|
|
318
|
+
OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
NSTextField *field = (NSTextField *)peer;
|
|
323
|
+
if (maxLength <= 0) {
|
|
324
|
+
field.formatter = nil;
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
RnAppKitLengthFormatter *formatter = [[RnAppKitLengthFormatter alloc] init];
|
|
328
|
+
formatter.maxLength = maxLength;
|
|
329
|
+
field.formatter = formatter;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
BOOL RnPeerAllowsChange(NSView *peer, NSRange range, NSString *replacement) {
|
|
333
|
+
if (peer == nil || !RnPeerIsMultiline(peer)) {
|
|
334
|
+
return YES;
|
|
335
|
+
}
|
|
336
|
+
NSNumber *limit = objc_getAssociatedObject(peer, @selector(RnPeerSetMaxLength));
|
|
337
|
+
const NSInteger maxLength = limit != nil ? limit.integerValue : 0;
|
|
338
|
+
if (maxLength <= 0) {
|
|
339
|
+
return YES;
|
|
340
|
+
}
|
|
341
|
+
const NSInteger current = (NSInteger)((NSTextView *)peer).string.length;
|
|
342
|
+
const NSInteger after = current - (NSInteger)range.length + (NSInteger)replacement.length;
|
|
343
|
+
return after <= maxLength;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
NSResponder *RnPeerEditor(NSView *peer) {
|
|
347
|
+
if (peer == nil) {
|
|
348
|
+
return nil;
|
|
349
|
+
}
|
|
350
|
+
// An NSTextView is the editor; an NSTextField borrows one from the window
|
|
351
|
+
// and only while it has focus.
|
|
352
|
+
return RnPeerIsMultiline(peer) ? (NSResponder *)peer : ((NSTextField *)peer).currentEditor;
|
|
353
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The window's title bar on macOS: its title, its colours, and whether the
|
|
2
|
+
// system draws it or the app does.
|
|
3
|
+
//
|
|
4
|
+
// The same two styles Win32TitleBar.h describes, and AppKit happens to offer
|
|
5
|
+
// both directly:
|
|
6
|
+
//
|
|
7
|
+
// - Native style. The window keeps its title bar, which sits *outside* the
|
|
8
|
+
// content view -- so it takes nothing from the app and every metric is zero,
|
|
9
|
+
// exactly as on Windows. Colours are the window's background plus a
|
|
10
|
+
// transparent title bar, which is how AppKit lets a caption take an app's
|
|
11
|
+
// colour at all; there is no per-element caption colour the way DWM has one.
|
|
12
|
+
//
|
|
13
|
+
// - Hidden style. NSWindowStyleMaskFullSizeContentView puts the content view
|
|
14
|
+
// under the title bar, so the app's content reaches the top of the window.
|
|
15
|
+
// The traffic lights stay -- the system keeps drawing them, which is the
|
|
16
|
+
// macOS counterpart of the host painting caption buttons on Windows -- and
|
|
17
|
+
// the metrics say how much room to leave for them.
|
|
18
|
+
//
|
|
19
|
+
// Main thread only, like the window. The BasaltWindow module posts here.
|
|
20
|
+
|
|
21
|
+
#pragma once
|
|
22
|
+
|
|
23
|
+
#import <Cocoa/Cocoa.h>
|
|
24
|
+
|
|
25
|
+
#include <cstdint>
|
|
26
|
+
#include <functional>
|
|
27
|
+
#include <optional>
|
|
28
|
+
#include <string>
|
|
29
|
+
|
|
30
|
+
namespace basalt {
|
|
31
|
+
|
|
32
|
+
enum class TitleBarStyle { Native, Hidden };
|
|
33
|
+
|
|
34
|
+
// What JavaScript is told, in points -- which are the surface root's units on
|
|
35
|
+
// this host. All zero while the style is native, because the system's title bar
|
|
36
|
+
// is outside the content view and takes nothing from the app.
|
|
37
|
+
struct TitleBarMetrics {
|
|
38
|
+
double height{0};
|
|
39
|
+
double buttonsWidth{0};
|
|
40
|
+
TitleBarStyle style{TitleBarStyle::Native};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
class AppKitTitleBar {
|
|
44
|
+
public:
|
|
45
|
+
// The window to talk to. Held weakly: the title bar outlives nothing.
|
|
46
|
+
void attach(NSWindow *window);
|
|
47
|
+
|
|
48
|
+
void setTitle(std::optional<std::string> title);
|
|
49
|
+
// 0xAARRGGBB as processColor produces, or nullopt for the system's own.
|
|
50
|
+
void setColors(std::optional<uint32_t> background,
|
|
51
|
+
std::optional<uint32_t> text,
|
|
52
|
+
std::optional<uint32_t> border);
|
|
53
|
+
void setStyle(TitleBarStyle style);
|
|
54
|
+
|
|
55
|
+
// What the caption buttons do, for an app that draws its own.
|
|
56
|
+
//
|
|
57
|
+
// The system's buttons work without any of this -- they are the system's --
|
|
58
|
+
// but a header the app drew has no way to act on itself otherwise, which
|
|
59
|
+
// makes the hidden style a picture of a title bar rather than one.
|
|
60
|
+
void minimize();
|
|
61
|
+
void toggleMaximize();
|
|
62
|
+
void close();
|
|
63
|
+
// Begins a window drag from the pointer's current position, which is what a
|
|
64
|
+
// caption does when you press it and move. Wanted for the same reason:
|
|
65
|
+
// without it an app-drawn header cannot move its own window.
|
|
66
|
+
void startDrag();
|
|
67
|
+
|
|
68
|
+
TitleBarMetrics metrics() const;
|
|
69
|
+
|
|
70
|
+
// Called whenever the metrics change, so JavaScript can re-lay-out. Cleared
|
|
71
|
+
// by the module's destructor.
|
|
72
|
+
void setMetricsListener(std::function<void(const TitleBarMetrics &)> listener);
|
|
73
|
+
|
|
74
|
+
private:
|
|
75
|
+
void apply();
|
|
76
|
+
void notify();
|
|
77
|
+
|
|
78
|
+
__weak NSWindow *window_{nil};
|
|
79
|
+
std::optional<std::string> title_;
|
|
80
|
+
std::optional<uint32_t> background_;
|
|
81
|
+
std::optional<uint32_t> text_;
|
|
82
|
+
std::optional<uint32_t> border_;
|
|
83
|
+
TitleBarStyle style_{TitleBarStyle::Native};
|
|
84
|
+
std::function<void(const TitleBarMetrics &)> listener_;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// The one per process, as the window is.
|
|
88
|
+
AppKitTitleBar &titleBar();
|
|
89
|
+
|
|
90
|
+
} // namespace basalt
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
#import "AppKitTitleBar.h"
|
|
2
|
+
|
|
3
|
+
#include <cstdint>
|
|
4
|
+
#include <functional>
|
|
5
|
+
|
|
6
|
+
namespace basalt {
|
|
7
|
+
|
|
8
|
+
namespace {
|
|
9
|
+
|
|
10
|
+
NSColor *colorFrom(uint32_t argb) {
|
|
11
|
+
// processColor's 0xAARRGGBB. sRGB explicitly, for the reason every other
|
|
12
|
+
// colour on this host is: letting AppKit pick a device space shifts it on a
|
|
13
|
+
// wide-gamut display, and the same app on Linux would not shift.
|
|
14
|
+
return [NSColor colorWithSRGBRed:((argb >> 16) & 0xFF) / 255.0
|
|
15
|
+
green:((argb >> 8) & 0xFF) / 255.0
|
|
16
|
+
blue:(argb & 0xFF) / 255.0
|
|
17
|
+
alpha:((argb >> 24) & 0xFF) / 255.0];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Whether a colour is dark enough to want light text over it. The same
|
|
21
|
+
// question DWMWA_USE_IMMERSIVE_DARK_MODE answers on Windows, asked here
|
|
22
|
+
// because AppKit has no caption text colour: the appearance decides it.
|
|
23
|
+
bool isDark(uint32_t argb) {
|
|
24
|
+
const double r = ((argb >> 16) & 0xFF) / 255.0;
|
|
25
|
+
const double g = ((argb >> 8) & 0xFF) / 255.0;
|
|
26
|
+
const double b = (argb & 0xFF) / 255.0;
|
|
27
|
+
// Rec. 601 luma, which is what every "is this dark" check uses and is close
|
|
28
|
+
// enough for a binary decision.
|
|
29
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) < 0.5;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
} // namespace
|
|
33
|
+
|
|
34
|
+
void AppKitTitleBar::attach(NSWindow *window) {
|
|
35
|
+
window_ = window;
|
|
36
|
+
apply();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
void AppKitTitleBar::setTitle(std::optional<std::string> title) {
|
|
40
|
+
title_ = std::move(title);
|
|
41
|
+
apply();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
void AppKitTitleBar::setColors(std::optional<uint32_t> background,
|
|
45
|
+
std::optional<uint32_t> text,
|
|
46
|
+
std::optional<uint32_t> border) {
|
|
47
|
+
background_ = background;
|
|
48
|
+
text_ = text;
|
|
49
|
+
border_ = border;
|
|
50
|
+
apply();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
void AppKitTitleBar::setStyle(TitleBarStyle style) {
|
|
54
|
+
if (style_ == style) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
style_ = style;
|
|
58
|
+
apply();
|
|
59
|
+
notify();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
void AppKitTitleBar::minimize() {
|
|
63
|
+
[window_ miniaturize:nil];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
void AppKitTitleBar::toggleMaximize() {
|
|
67
|
+
// `zoom:` rather than a maximised flag: on macOS the green button toggles
|
|
68
|
+
// between the window's frame and the "best" one for the screen, and there is
|
|
69
|
+
// no maximised state to set.
|
|
70
|
+
[window_ zoom:nil];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
void AppKitTitleBar::close() {
|
|
74
|
+
// performClose: rather than close:, so the delegate gets its say and the
|
|
75
|
+
// window closes the way the red button closes it.
|
|
76
|
+
[window_ performClose:nil];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
void AppKitTitleBar::startDrag() {
|
|
80
|
+
NSWindow *window = window_;
|
|
81
|
+
if (window == nil) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
// The event currently being handled, which is the press that started this.
|
|
85
|
+
// AppKit takes over the drag from there and runs it until the mouse is
|
|
86
|
+
// released.
|
|
87
|
+
NSEvent *event = NSApp.currentEvent;
|
|
88
|
+
if (event != nil) {
|
|
89
|
+
[window performWindowDragWithEvent:event];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
TitleBarMetrics AppKitTitleBar::metrics() const {
|
|
94
|
+
TitleBarMetrics result;
|
|
95
|
+
result.style = style_;
|
|
96
|
+
if (style_ == TitleBarStyle::Native) {
|
|
97
|
+
// The system's title bar is outside the content view, so it costs the app
|
|
98
|
+
// nothing and there is nothing to report.
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
NSWindow *window = window_;
|
|
103
|
+
if (window == nil) {
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// What the content view gained by extending under the title bar, which is
|
|
108
|
+
// exactly what the app has to leave clear.
|
|
109
|
+
//
|
|
110
|
+
// Not `contentRectForFrameRect:`: with NSWindowStyleMaskFullSizeContentView
|
|
111
|
+
// the content rect *is* the frame, so that difference is zero and says the
|
|
112
|
+
// title bar costs nothing -- which is true of the window and false of the
|
|
113
|
+
// app. `contentLayoutRect` is the part not covered by the title bar, which
|
|
114
|
+
// is the question actually being asked.
|
|
115
|
+
result.height = NSHeight(window.contentView.frame) - NSHeight(window.contentLayoutRect);
|
|
116
|
+
|
|
117
|
+
// The traffic lights, from where they actually are rather than from a
|
|
118
|
+
// constant: their size and spacing are the system's to change.
|
|
119
|
+
NSButton *close = [window standardWindowButton:NSWindowCloseButton];
|
|
120
|
+
NSButton *zoom = [window standardWindowButton:NSWindowZoomButton];
|
|
121
|
+
if (close != nil && zoom != nil) {
|
|
122
|
+
const NSRect closeFrame = close.frame;
|
|
123
|
+
const NSRect zoomFrame = zoom.frame;
|
|
124
|
+
// From the window's left edge to the far side of the last button, which is
|
|
125
|
+
// the width an app has to keep clear -- the buttons sit at the left on
|
|
126
|
+
// macOS, where Windows puts them at the right.
|
|
127
|
+
result.buttonsWidth = NSMaxX(zoomFrame) + NSMinX(closeFrame);
|
|
128
|
+
}
|
|
129
|
+
return result;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
void AppKitTitleBar::setMetricsListener(std::function<void(const TitleBarMetrics &)> listener) {
|
|
133
|
+
listener_ = std::move(listener);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
void AppKitTitleBar::apply() {
|
|
137
|
+
NSWindow *window = window_;
|
|
138
|
+
if (window == nil) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (title_.has_value()) {
|
|
143
|
+
NSString *value = [NSString stringWithUTF8String:title_->c_str()];
|
|
144
|
+
window.title = value != nil ? value : @"";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const bool hidden = style_ == TitleBarStyle::Hidden;
|
|
148
|
+
window.titlebarAppearsTransparent = hidden || background_.has_value();
|
|
149
|
+
// The title text would otherwise sit over the app's own content.
|
|
150
|
+
window.titleVisibility = hidden ? NSWindowTitleHidden : NSWindowTitleVisible;
|
|
151
|
+
|
|
152
|
+
if (hidden) {
|
|
153
|
+
window.styleMask |= NSWindowStyleMaskFullSizeContentView;
|
|
154
|
+
} else {
|
|
155
|
+
window.styleMask &= ~NSWindowStyleMaskFullSizeContentView;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (background_.has_value()) {
|
|
159
|
+
window.backgroundColor = colorFrom(*background_);
|
|
160
|
+
// AppKit has no caption *text* colour: the window's appearance decides it.
|
|
161
|
+
// So a dark caption gets the dark appearance, which is the same decision
|
|
162
|
+
// Windows makes through DWMWA_USE_IMMERSIVE_DARK_MODE -- and honouring an
|
|
163
|
+
// explicit textColor beyond that is not something AppKit offers.
|
|
164
|
+
const bool wantsLight = text_.has_value() ? !isDark(*text_) : isDark(*background_);
|
|
165
|
+
window.appearance = [NSAppearance appearanceNamed:wantsLight ? NSAppearanceNameDarkAqua
|
|
166
|
+
: NSAppearanceNameAqua];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// `border` has no AppKit equivalent. A window's edge is the system's, and
|
|
170
|
+
// there is no DWMWA_BORDER_COLOR here -- recorded rather than silently
|
|
171
|
+
// dropped, so the asymmetry is visible where it is decided.
|
|
172
|
+
(void)border_;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
void AppKitTitleBar::notify() {
|
|
176
|
+
if (listener_) {
|
|
177
|
+
listener_(metrics());
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
AppKitTitleBar &titleBar() {
|
|
182
|
+
static AppKitTitleBar instance;
|
|
183
|
+
return instance;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
} // namespace basalt
|