Repository navigation
Expand file tree
/
Copy pathcocoa_gui_darwin.go
More file actions
1542 lines (1449 loc) · 54.6 KB
/
Copy pathcocoa_gui_darwin.go
File metadata and controls
1542 lines (1449 loc) · 54.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
//go:build darwin && !ios && !vtui_nococoa
package vtui
import (
"fmt"
"io"
"math"
"os"
"runtime"
"runtime/debug"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
"github.com/ebitengine/purego"
"github.com/ebitengine/purego/objc"
"github.com/unxed/vtinput"
)
// The Cocoa backend: an NSApplication with one NSWindow whose content view
// is a class of our own, registered with the Objective-C runtime through
// purego, so no cgo is involved anywhere. The grid is composed on the CPU by
// the raster the Win32 backend uses and handed to Core Animation as the
// contents of the view's layer, one CGImage per displayed frame.
//
// AppKit is single-threaded: windows, views, layers and the event loop all
// belong to the main thread. The rules this file keeps are therefore:
//
// - RunCocoaGuiHost runs on the main thread (see init below) and ends up in
// [NSApp run]; every Objective-C callback into this file arrives there.
// - FrameManager renders on its own goroutine into the renderer's bitmap,
// under the renderer's lock, exactly as it does for the Win32 backend.
// - Anything that goroutine needs done to the window -- a display pass, a
// new title, a resize, the final stop -- is queued and handed to the main
// thread with performSelectorOnMainThread:, never done in place.
// - Input handlers on the main thread never block: they post into the
// vtinput channel without waiting.
func init() {
// AppKit runs on the process's main thread and nowhere else, and the main
// goroutine only stays on that thread if it is locked there before main
// starts. gogpu and Ebitengine do the same in their own darwin packages;
// this is what keeps it true in a build that leaves both of them out.
runtime.LockOSThread()
}
type cocoaPoint struct{ X, Y float64 }
type cocoaSize struct{ Width, Height float64 }
type cocoaRect struct {
Origin cocoaPoint
Size cocoaSize
}
const (
nsWindowStyleMaskTitled = 1 << 0
nsWindowStyleMaskClosable = 1 << 1
nsWindowStyleMaskMiniaturizable = 1 << 2
nsWindowStyleMaskResizable = 1 << 3
nsBackingStoreBuffered = 2
nsApplicationActivationPolicyRegular = 0
nsWindowTabbingModeDisallowed = 2
nsViewLayerContentsRedrawOnSetNeedsDsp = 1
nsViewLayerContentsPlacementTopLeft = 11
nsTerminateCancel = 0
nsTerminateNow = 1
nsTerminateLater = 2
nsUTF8StringEncoding = 4
nsEventTypeKeyDown = 10
nsEventTypeApplicationDefined = 15
// RGBX, eight bits a channel, in the byte order image.RGBA keeps.
cgImageAlphaNoneSkipLast = 5
)
// The Objective-C classes and selectors this file uses, resolved once.
var cocoaRT struct {
once sync.Once
err error
classNSApplication objc.Class
classNSWindow objc.Class
classNSEvent objc.Class
classNSString objc.Class
classNSScreen objc.Class
classNSMenu objc.Class
classNSMenuItem objc.Class
classNSColor objc.Class
classView objc.Class
classWindowDelegate objc.Class
classApplicationDelegate objc.Class
colorSpace uintptr
}
var cocoaSel struct {
alloc, init, release objc.SEL
sharedApplication, setActivationPolicy objc.SEL
activateIgnoringOtherApps, run, stop objc.SEL
postEventAtStart, mainMenu, setMainMenu objc.SEL
delegate, setDelegate objc.SEL
mainScreen, screens, objectAtIndex objc.SEL
backingScaleFactor, frame objc.SEL
initWithContentRect, setTitle objc.SEL
setContentView, makeFirstResponder objc.SEL
setAcceptsMouseMovedEvents objc.SEL
setReleasedWhenClosed, setRestorable objc.SEL
center, makeKeyAndOrderFront, orderOut objc.SEL
setContentSize, setContentMinSize objc.SEL
setContentResizeIncrements, zoom, close objc.SEL
setFrame, setFrameTopLeftPoint objc.SEL
setBackgroundColor objc.SEL
blackColor, respondsToSelector objc.SEL
setTabbingMode objc.SEL
convertRectFromScreen, window objc.SEL
initWithFrame, setWantsLayer, layer objc.SEL
setLayerContentsRedrawPolicy objc.SEL
setLayerContentsPlacement objc.SEL
setNeedsDisplay, bounds, convertPoint objc.SEL
performOnMain, setContents objc.SEL
setContentsScale objc.SEL
keyCode, modifierFlags, characters objc.SEL
charactersIgnoringModifiers, eventType objc.SEL
locationInWindow, clickCount objc.SEL
buttonNumber, scrollingDeltaY objc.SEL
hasPreciseScrollingDeltas, otherEvent objc.SEL
initWithUTF8String, lengthOfBytes objc.SEL
getCString objc.SEL
initWithTitle, addItem, addItemWithTitle objc.SEL
separatorItem, setSubmenu objc.SEL
hide, terminate, performKeyEquivalent objc.SEL
runQueued objc.SEL
replyToApplicationShouldTerminate objc.SEL
vtuiReplyTerminate objc.SEL
}
// Plain C functions: Core Graphics for the frame, libobjc for autorelease
// pools on goroutines, libSystem to tell the main thread from the others.
var (
cgColorSpaceCreateDeviceRGB func() uintptr
cgColorSpaceCreateWithName func(name uintptr) uintptr
cgBitmapContextCreate func(data unsafe.Pointer, width, height, bitsPerComponent, bytesPerRow uintptr, space uintptr, bitmapInfo uint32) uintptr
cgBitmapContextGetData func(ctx uintptr) unsafe.Pointer
cgBitmapContextGetBytesPerRow func(ctx uintptr) uintptr
cgBitmapContextCreateImage func(ctx uintptr) uintptr
cgContextRelease func(ctx uintptr)
cgImageRelease func(image uintptr)
objcAutoreleasePoolPush func() uintptr
objcAutoreleasePoolPop func(pool uintptr)
pthreadMainNP func() int32
)
// cocoaHosts maps the view and the window delegate to the host they belong
// to; cocoaActiveHost is the host of the running loop, for the application
// delegate, which belongs to no window. cocoaAppDelegate is NSApp's
// delegate, set once in RunCocoaGuiHost's setup and read from any thread
// after that (see cocoaAppDelegate's use in cocoaReplyOnceClosed): the `go`
// statement that starts a goroutine after that point happens-after the
// assignment, so this needs no lock.
var (
cocoaHosts sync.Map
cocoaActiveHost atomic.Pointer[CocoaGuiHost]
cocoaAppDelegate objc.ID
)
func cocoaHostFor(id objc.ID) *CocoaGuiHost {
if v, ok := cocoaHosts.Load(id); ok {
return v.(*CocoaGuiHost)
}
return nil
}
// loadCocoa loads AppKit and Core Graphics, resolves what this file calls
// and registers the classes, once per process.
func loadCocoa() error {
cocoaRT.once.Do(func() {
cocoaRT.err = loadCocoaOnce()
})
return cocoaRT.err
}
func loadCocoaOnce() error {
// With cgo off nothing links the frameworks in, so the classes do not
// exist until the frameworks are loaded by hand.
if _, err := purego.Dlopen("/System/Library/Frameworks/AppKit.framework/AppKit", purego.RTLD_LAZY|purego.RTLD_GLOBAL); err != nil {
return fmt.Errorf("cocoa: loading AppKit: %w", err)
}
cg, err := purego.Dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("cocoa: loading CoreGraphics: %w", err)
}
libobjc, err := purego.Dlopen("/usr/lib/libobjc.A.dylib", purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("cocoa: loading libobjc: %w", err)
}
libSystem, err := purego.Dlopen("/usr/lib/libSystem.B.dylib", purego.RTLD_LAZY|purego.RTLD_GLOBAL)
if err != nil {
return fmt.Errorf("cocoa: loading libSystem: %w", err)
}
for _, fn := range []struct {
lib uintptr
name string
ptr any
}{
{cg, "CGColorSpaceCreateDeviceRGB", &cgColorSpaceCreateDeviceRGB},
{cg, "CGColorSpaceCreateWithName", &cgColorSpaceCreateWithName},
{cg, "CGBitmapContextCreate", &cgBitmapContextCreate},
{cg, "CGBitmapContextGetData", &cgBitmapContextGetData},
{cg, "CGBitmapContextGetBytesPerRow", &cgBitmapContextGetBytesPerRow},
{cg, "CGBitmapContextCreateImage", &cgBitmapContextCreateImage},
{cg, "CGContextRelease", &cgContextRelease},
{cg, "CGImageRelease", &cgImageRelease},
{libobjc, "objc_autoreleasePoolPush", &objcAutoreleasePoolPush},
{libobjc, "objc_autoreleasePoolPop", &objcAutoreleasePoolPop},
{libSystem, "pthread_main_np", &pthreadMainNP},
} {
sym, err := purego.Dlsym(fn.lib, fn.name)
if err != nil {
return fmt.Errorf("cocoa: resolving %s: %w", fn.name, err)
}
purego.RegisterFunc(fn.ptr, sym)
}
s := &cocoaSel
for _, sel := range []struct {
dst *objc.SEL
name string
}{
{&s.alloc, "alloc"}, {&s.init, "init"}, {&s.release, "release"},
{&s.sharedApplication, "sharedApplication"},
{&s.setActivationPolicy, "setActivationPolicy:"},
{&s.activateIgnoringOtherApps, "activateIgnoringOtherApps:"},
{&s.run, "run"}, {&s.stop, "stop:"},
{&s.postEventAtStart, "postEvent:atStart:"},
{&s.mainMenu, "mainMenu"}, {&s.setMainMenu, "setMainMenu:"},
{&s.delegate, "delegate"}, {&s.setDelegate, "setDelegate:"},
{&s.mainScreen, "mainScreen"}, {&s.screens, "screens"},
{&s.objectAtIndex, "objectAtIndex:"},
{&s.backingScaleFactor, "backingScaleFactor"}, {&s.frame, "frame"},
{&s.initWithContentRect, "initWithContentRect:styleMask:backing:defer:"},
{&s.setTitle, "setTitle:"}, {&s.setContentView, "setContentView:"},
{&s.makeFirstResponder, "makeFirstResponder:"},
{&s.setAcceptsMouseMovedEvents, "setAcceptsMouseMovedEvents:"},
{&s.setReleasedWhenClosed, "setReleasedWhenClosed:"},
{&s.setRestorable, "setRestorable:"},
{&s.center, "center"}, {&s.makeKeyAndOrderFront, "makeKeyAndOrderFront:"},
{&s.orderOut, "orderOut:"},
{&s.setContentSize, "setContentSize:"},
{&s.setContentMinSize, "setContentMinSize:"},
{&s.setContentResizeIncrements, "setContentResizeIncrements:"},
{&s.zoom, "zoom:"}, {&s.close, "close"},
{&s.setFrame, "setFrame:display:"},
{&s.setFrameTopLeftPoint, "setFrameTopLeftPoint:"},
{&s.setBackgroundColor, "setBackgroundColor:"},
{&s.blackColor, "blackColor"},
{&s.respondsToSelector, "respondsToSelector:"},
{&s.setTabbingMode, "setTabbingMode:"},
{&s.convertRectFromScreen, "convertRectFromScreen:"},
{&s.window, "window"},
{&s.initWithFrame, "initWithFrame:"},
{&s.setWantsLayer, "setWantsLayer:"}, {&s.layer, "layer"},
{&s.setLayerContentsRedrawPolicy, "setLayerContentsRedrawPolicy:"},
{&s.setLayerContentsPlacement, "setLayerContentsPlacement:"},
{&s.setNeedsDisplay, "setNeedsDisplay:"}, {&s.bounds, "bounds"},
{&s.convertPoint, "convertPoint:fromView:"},
{&s.performOnMain, "performSelectorOnMainThread:withObject:waitUntilDone:"},
{&s.setContents, "setContents:"},
{&s.setContentsScale, "setContentsScale:"},
{&s.keyCode, "keyCode"}, {&s.modifierFlags, "modifierFlags"},
{&s.characters, "characters"},
{&s.charactersIgnoringModifiers, "charactersIgnoringModifiers"},
{&s.eventType, "type"},
{&s.locationInWindow, "locationInWindow"},
{&s.clickCount, "clickCount"}, {&s.buttonNumber, "buttonNumber"},
{&s.scrollingDeltaY, "scrollingDeltaY"},
{&s.hasPreciseScrollingDeltas, "hasPreciseScrollingDeltas"},
{&s.otherEvent, "otherEventWithType:location:modifierFlags:timestamp:windowNumber:context:subtype:data1:data2:"},
{&s.initWithUTF8String, "initWithUTF8String:"},
{&s.lengthOfBytes, "lengthOfBytesUsingEncoding:"},
{&s.getCString, "getCString:maxLength:encoding:"},
{&s.initWithTitle, "initWithTitle:"}, {&s.addItem, "addItem:"},
{&s.addItemWithTitle, "addItemWithTitle:action:keyEquivalent:"},
{&s.separatorItem, "separatorItem"}, {&s.setSubmenu, "setSubmenu:"},
{&s.hide, "hide:"}, {&s.terminate, "terminate:"},
{&s.performKeyEquivalent, "performKeyEquivalent:"},
{&s.runQueued, "vtuiRunQueued:"},
{&s.replyToApplicationShouldTerminate, "replyToApplicationShouldTerminate:"},
{&s.vtuiReplyTerminate, "vtuiReplyTerminate:"},
} {
*sel.dst = objc.RegisterName(sel.name)
}
for _, c := range []struct {
dst *objc.Class
name string
}{
{&cocoaRT.classNSApplication, "NSApplication"},
{&cocoaRT.classNSWindow, "NSWindow"},
{&cocoaRT.classNSEvent, "NSEvent"},
{&cocoaRT.classNSString, "NSString"},
{&cocoaRT.classNSScreen, "NSScreen"},
{&cocoaRT.classNSMenu, "NSMenu"},
{&cocoaRT.classNSMenuItem, "NSMenuItem"},
{&cocoaRT.classNSColor, "NSColor"},
} {
if *c.dst = objc.GetClass(c.name); *c.dst == 0 {
return fmt.Errorf("cocoa: class %s not found", c.name)
}
}
if err := registerCocoaClasses(); err != nil {
return err
}
// sRGB, so a palette colour means on screen what it means in every
// terminal; the name is compared by value, so an NSString of our own
// stands in for the kCGColorSpaceSRGB constant.
name := cocoaNSString("kCGColorSpaceSRGB")
cocoaRT.colorSpace = cgColorSpaceCreateWithName(uintptr(name))
name.Send(cocoaSel.release)
if cocoaRT.colorSpace == 0 {
cocoaRT.colorSpace = cgColorSpaceCreateDeviceRGB()
}
if cocoaRT.colorSpace == 0 {
return fmt.Errorf("cocoa: no RGB colour space")
}
return nil
}
// registerCocoaClasses registers the content view, the window delegate and
// the application delegate. Every method looks its host up by the receiver
// and does nothing when there is none: a callback can still be queued for a
// window whose loop has ended.
func registerCocoaClasses() error {
mouse := func(name string, btn uint32, down bool) objc.MethodDef {
return objc.MethodDef{
Cmd: objc.RegisterName(name),
Fn: func(self objc.ID, _ objc.SEL, event objc.ID) {
defer cocoaCallbackRecover(name)
if h := cocoaHostFor(self); h != nil {
h.handleMouseButton(self, event, btn, down)
}
},
}
}
motion := func(name string) objc.MethodDef {
return objc.MethodDef{
Cmd: objc.RegisterName(name),
Fn: func(self objc.ID, _ objc.SEL, event objc.ID) {
defer cocoaCallbackRecover(name)
if h := cocoaHostFor(self); h != nil {
h.handleMouseMotion(self, event)
}
},
}
}
yes := func(name string) objc.MethodDef {
return objc.MethodDef{Cmd: objc.RegisterName(name), Fn: func(objc.ID, objc.SEL) bool { return true }}
}
var err error
cocoaRT.classView, err = objc.RegisterClass("VtuiCocoaView", objc.GetClass("NSView"), nil, nil, []objc.MethodDef{
yes("acceptsFirstResponder"),
yes("canBecomeKeyView"),
yes("isOpaque"),
yes("wantsUpdateLayer"),
{Cmd: objc.RegisterName("acceptsFirstMouse:"), Fn: func(objc.ID, objc.SEL, objc.ID) bool { return true }},
{Cmd: objc.RegisterName("updateLayer"), Fn: func(self objc.ID, _ objc.SEL) {
defer cocoaCallbackRecover("updateLayer")
if h := cocoaHostFor(self); h != nil {
h.presentFrame(self)
}
}},
{Cmd: objc.RegisterName("keyDown:"), Fn: func(self objc.ID, _ objc.SEL, event objc.ID) {
defer cocoaCallbackRecover("keyDown:")
if h := cocoaHostFor(self); h != nil {
h.handleKeyDown(event)
}
}},
{Cmd: objc.RegisterName("keyUp:"), Fn: func(self objc.ID, _ objc.SEL, event objc.ID) {
defer cocoaCallbackRecover("keyUp:")
if h := cocoaHostFor(self); h != nil {
h.handleKeyUp(event)
}
}},
{Cmd: objc.RegisterName("flagsChanged:"), Fn: func(self objc.ID, _ objc.SEL, event objc.ID) {
defer cocoaCallbackRecover("flagsChanged:")
if h := cocoaHostFor(self); h != nil {
h.handleFlagsChanged(event)
}
}},
{Cmd: cocoaSel.performKeyEquivalent, Fn: func(self objc.ID, _ objc.SEL, event objc.ID) bool {
defer cocoaCallbackRecover("performKeyEquivalent:")
if h := cocoaHostFor(self); h != nil && h.claimsKeyEquivalent(event) {
h.handleKeyDown(event)
return true
}
// objc.SendSuper resolves the super class from self's dynamic
// class (object_getClass(self)), which is exactly what
// objc_msgSendSuper2 itself expects. That is safe as long as
// self's dynamic class is really VtuiCocoaView: the runtime
// then starts the search one level up, at NSView. But if this
// particular view instance ever gets isa-swizzled -- most
// commonly by Cocoa's KVO machinery, which installs a private
// NSKVONotifying_VtuiCocoaView subclass the moment an observer
// is added to it -- self's dynamic class becomes that
// subclass, and the same lookup would start one level up from
// *there*, landing back on VtuiCocoaView itself and calling
// straight back into this method: unbounded recursion. Guard
// against that by only forwarding to super while self is
// still exactly our own registered class; otherwise fall back
// to NSView's real default, which is simply to not handle the
// key equivalent.
if self.Class() == cocoaRT.classView {
return objc.SendSuper[bool](self, cocoaSel.performKeyEquivalent, event)
}
return false
}},
mouse("mouseDown:", uint32(vtinput.FromLeft1stButtonPressed), true),
mouse("mouseUp:", uint32(vtinput.FromLeft1stButtonPressed), false),
mouse("rightMouseDown:", uint32(vtinput.RightmostButtonPressed), true),
mouse("rightMouseUp:", uint32(vtinput.RightmostButtonPressed), false),
mouse("otherMouseDown:", 0, true),
mouse("otherMouseUp:", 0, false),
motion("mouseMoved:"),
motion("mouseDragged:"),
motion("rightMouseDragged:"),
motion("otherMouseDragged:"),
{Cmd: objc.RegisterName("scrollWheel:"), Fn: func(self objc.ID, _ objc.SEL, event objc.ID) {
defer cocoaCallbackRecover("scrollWheel:")
if h := cocoaHostFor(self); h != nil {
h.handleScroll(self, event)
}
}},
// Text that arrives without a key press of its own: the Character
// Viewer and the Services menu hand it to the first responder this
// way.
{Cmd: objc.RegisterName("insertText:"), Fn: func(self objc.ID, _ objc.SEL, text objc.ID) {
defer cocoaCallbackRecover("insertText:")
if h := cocoaHostFor(self); h != nil {
h.insertText(cocoaGoString(text))
}
}},
{Cmd: cocoaSel.runQueued, Fn: func(self objc.ID, _ objc.SEL, _ objc.ID) {
defer cocoaCallbackRecover("vtuiRunQueued:")
if h := cocoaHostFor(self); h != nil {
h.runQueued()
}
}},
})
if err != nil {
return fmt.Errorf("cocoa: registering the view class: %w", err)
}
cocoaRT.classWindowDelegate, err = objc.RegisterClass("VtuiCocoaWindowDelegate", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{
{Cmd: objc.RegisterName("windowShouldClose:"), Fn: func(self objc.ID, _ objc.SEL, _ objc.ID) bool {
defer cocoaCallbackRecover("windowShouldClose:")
h := cocoaHostFor(self)
if h == nil || h.isClosed() || FrameManager.IsShutdown() {
return true
}
// The application decides: it may ask about unsaved work
// first, and it quits through the same command a key would.
postQuitCommand()
return false
}},
{Cmd: objc.RegisterName("windowWillClose:"), Fn: func(objc.ID, objc.SEL, objc.ID) {
defer cocoaCallbackRecover("windowWillClose:")
cocoaStopApp()
}},
{Cmd: objc.RegisterName("windowDidResize:"), Fn: func(self objc.ID, _ objc.SEL, _ objc.ID) {
defer cocoaCallbackRecover("windowDidResize:")
if h := cocoaHostFor(self); h != nil {
h.updateGridSize()
}
}},
{Cmd: objc.RegisterName("windowDidBecomeKey:"), Fn: func(self objc.ID, _ objc.SEL, _ objc.ID) {
defer cocoaCallbackRecover("windowDidBecomeKey:")
if h := cocoaHostFor(self); h != nil {
h.sendEvent(&vtinput.InputEvent{Type: vtinput.FocusEventType, SetFocus: true})
}
}},
{Cmd: objc.RegisterName("windowDidResignKey:"), Fn: func(self objc.ID, _ objc.SEL, _ objc.ID) {
defer cocoaCallbackRecover("windowDidResignKey:")
if h := cocoaHostFor(self); h != nil {
// A button released over another window never reaches
// this one.
h.mouseBtn = 0
h.keys.reset()
h.sendEvent(&vtinput.InputEvent{Type: vtinput.FocusEventType, SetFocus: false})
}
}},
{Cmd: objc.RegisterName("windowDidChangeBackingProperties:"), Fn: func(self objc.ID, _ objc.SEL, _ objc.ID) {
defer cocoaCallbackRecover("windowDidChangeBackingProperties:")
if h := cocoaHostFor(self); h != nil && h.window != 0 {
// The font stays rasterised for the display the window
// opened on; Core Animation scales the frame for this one.
DebugLog("COCOA: backing scale factor is now %v (frames stay at %v)",
objc.Send[float64](h.window, cocoaSel.backingScaleFactor), h.pointScale)
h.view.Send(cocoaSel.setNeedsDisplay, true)
}
}},
})
if err != nil {
return fmt.Errorf("cocoa: registering the window delegate class: %w", err)
}
// No NSApplicationDelegate protocol is declared: on macOS 14 declaring
// it has been seen to crash AppKit inside nextEventMatchingMask:
// (Ebitengine issue #3451), and AppKit finds the methods through
// respondsToSelector: regardless.
cocoaRT.classApplicationDelegate, err = objc.RegisterClass("VtuiCocoaApplicationDelegate", objc.GetClass("NSObject"), nil, nil, []objc.MethodDef{
{Cmd: objc.RegisterName("applicationShouldTerminate:"), Fn: func(objc.ID, objc.SEL, objc.ID) uint {
defer cocoaCallbackRecover("applicationShouldTerminate:")
// Quit from the menu, or a logout, restart or shutdown. Never let
// AppKit call exit() under the application: ask it to quit as the
// close button does, or end the loop if it has already gone.
// AppKit is told to wait rather than refused outright (returning
// NSTerminateCancel here would otherwise refuse every logout,
// restart and shutdown for good); cocoaReplyOnceClosed answers it
// once that quit has actually finished.
h := cocoaActiveHost.Load()
if h == nil {
// Nothing is open: there is nothing left to shut down, so
// there is nothing to wait for either.
return nsTerminateNow
}
if h.isClosed() || FrameManager.IsShutdown() {
cocoaStopApp()
} else {
postQuitCommand()
}
go cocoaReplyOnceClosed(h)
return nsTerminateLater
}},
{Cmd: cocoaSel.vtuiReplyTerminate, Fn: func(objc.ID, objc.SEL, objc.ID) {
defer cocoaCallbackRecover("vtuiReplyTerminate:")
app := objc.ID(cocoaRT.classNSApplication).Send(cocoaSel.sharedApplication)
app.Send(cocoaSel.replyToApplicationShouldTerminate, true)
}},
{Cmd: objc.RegisterName("applicationShouldTerminateAfterLastWindowClosed:"), Fn: func(objc.ID, objc.SEL, objc.ID) bool {
return false
}},
{Cmd: objc.RegisterName("applicationDidFinishLaunching:"), Fn: func(objc.ID, objc.SEL, objc.ID) {
defer cocoaCallbackRecover("applicationDidFinishLaunching:")
// -run calls finishLaunching itself, the first time it runs, before
// it starts pumping events; this delegate method is where AppKit
// considers the app fully launched. Activating any earlier --
// right after setActivationPolicy: promotes this ordinary
// executable (no bundle, so no Info.plist to say it upfront) to a
// regular, Dock-visible app, and before -run had even been sent --
// asked the Window Server to bring a not-yet-fully-registered app
// to the front (vtui #1571).
app := objc.ID(cocoaRT.classNSApplication).Send(cocoaSel.sharedApplication)
app.Send(cocoaSel.activateIgnoringOtherApps, true)
}},
})
if err != nil {
return fmt.Errorf("cocoa: registering the application delegate class: %w", err)
}
return nil
}
// cocoaCallbackRecover ends the process on a panic in a method AppKit
// called. The panic must not unwind into Objective-C frames, which is
// undefined, so it is recorded the way FrameManager.Run records one and the
// process exits.
func cocoaCallbackRecover(where string) {
r := recover()
if r == nil {
return
}
stack := debug.Stack()
DebugLog("FATAL PANIC in Cocoa callback %s: %v\n%s", where, r, stack)
crashPath := RecordCrash(r, nil)
fmt.Fprintf(os.Stderr, "\n[%s] FATAL PANIC in Cocoa callback %s: %v\n%s\n", AppName, where, r, stack)
if crashPath != "" {
fmt.Fprintf(os.Stderr, "[%s] Crash report saved to: %s\n", AppName, crashPath)
}
os.Exit(2)
}
// cocoaNSString returns a new NSString the caller releases.
func cocoaNSString(s string) objc.ID {
return objc.ID(cocoaRT.classNSString).Send(cocoaSel.alloc).Send(cocoaSel.initWithUTF8String, s)
}
// cocoaGoString copies an NSString into a Go string.
func cocoaGoString(str objc.ID) string {
if str == 0 {
return ""
}
n := objc.Send[uint](str, cocoaSel.lengthOfBytes, uint(nsUTF8StringEncoding))
if n == 0 {
return ""
}
buf := make([]byte, n+1)
if !objc.Send[bool](str, cocoaSel.getCString, &buf[0], uint(len(buf)), uint(nsUTF8StringEncoding)) {
return ""
}
return string(buf[:n])
}
func cocoaOnMainThread() bool {
return pthreadMainNP != nil && pthreadMainNP() != 0
}
// cocoaPerformOnMain sends performSelectorOnMainThread:withObject:
// waitUntilDone: from any goroutine. The goroutine stays on its thread for
// the duration, because the autorelease pool around the call belongs to the
// thread.
func cocoaPerformOnMain(target objc.ID, sel objc.SEL, wait bool) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
pool := objcAutoreleasePoolPush()
defer objcAutoreleasePoolPop(pool)
target.Send(cocoaSel.performOnMain, sel, objc.ID(0), wait)
}
// cocoaStopApp ends [NSApp run]. stop: only takes effect once the loop has
// dispatched another event, so one is posted behind it.
func cocoaStopApp() {
app := objc.ID(cocoaRT.classNSApplication).Send(cocoaSel.sharedApplication)
app.Send(cocoaSel.stop, objc.ID(0))
event := objc.Send[objc.ID](objc.ID(cocoaRT.classNSEvent), cocoaSel.otherEvent,
uint(nsEventTypeApplicationDefined), cocoaPoint{}, uint(0), float64(0), 0, objc.ID(0), uintptr(0), 0, 0)
if event != 0 {
app.Send(cocoaSel.postEventAtStart, event, true)
}
}
// cocoaReplyOnceClosed waits for h's own quit sequence -- the one the close
// button and Cmd+Q trigger -- to reach the end of close(), and then answers
// applicationShouldTerminate:'s NSTerminateLater so a logout, restart or
// shutdown that was waiting on it can proceed. AppKit will not act on the
// reply until this arrives, so a wedged close() would otherwise hang the
// logout instead of merely refusing it; the timeout answers anyway rather
// than letting that happen.
func cocoaReplyOnceClosed(h *CocoaGuiHost) {
select {
case <-h.hostClosed:
case <-time.After(10 * time.Second):
DebugLog("COCOA: close() did not finish within 10s of a termination request; replying anyway")
}
if cocoaAppDelegate != 0 {
cocoaPerformOnMain(cocoaAppDelegate, cocoaSel.vtuiReplyTerminate, false)
}
}
// cocoaPrimaryScreenTop is the top edge of the main display in AppKit's
// screen coordinates, which grow upwards from its bottom edge.
func cocoaPrimaryScreenTop() float64 {
screens := objc.ID(cocoaRT.classNSScreen).Send(cocoaSel.screens)
if screens == 0 {
return 0
}
f := objc.Send[cocoaRect](screens.Send(cocoaSel.objectAtIndex, uint(0)), cocoaSel.frame)
return f.Origin.Y + f.Size.Height
}
// CocoaGuiHost owns the window. The fields after the mainOnly mark are
// touched on the main thread only and need no lock.
type CocoaGuiHost struct {
mu sync.Mutex
renderer *CocoaGuiRenderer
reader *vtinput.Reader
scr *ScreenBuf
cols int
rows int
closed bool
// cellW and cellH are the cell size in pixels. scale is the integer
// line-thickness factor the raster takes, pointScale the backing scale
// factor the window opened with: pixels per point. None of them change
// once the window exists.
cellW, cellH int
scale int
pointScale float64
app, window, view, windowDelegate objc.ID
// mainMu guards window, view and windowDelegate above, not just
// mainQueue and loopDone: close() zeroes all three under it once the
// loop has ended, so runOnMain's gate also keeps every other method from
// reading them once they are stale (vtui #1571).
mainMu sync.Mutex
mainQueue []func()
loopDone bool
// displayQueued keeps at most one display request in flight.
displayQueued atomic.Bool
// hostClosed is closed once close() has finished releasing the window,
// for applicationShouldTerminate: to wait on before it may tell AppKit a
// pending logout, restart or shutdown can proceed.
hostClosed chan struct{}
// mainOnly
bitmap uintptr
bitmapW int
bitmapH int
mouseBtn uint32
lastMouseCellX int16
lastMouseCellY int16
mouseCellKnown bool
wheelAcc float64
keys cocoaKeyTranslator
}
func (h *CocoaGuiHost) isClosed() bool {
h.mu.Lock()
defer h.mu.Unlock()
return h.closed
}
// runOnMain runs fn on the main thread: at once when called there, else
// queued for the main loop. Work queued after the loop has ended is
// dropped; there is no window left for it to act on. This includes the
// caller already being on the main thread: f4's deferred SaveSession, and
// anything else called from the same goroutine that ran RunInGUIWindow,
// keeps running there once RunInGUIWindow has returned, which is exactly
// when window, view and windowDelegate have already been released (vtui
// #1571) -- so that path needs the same loopDone check as the queued one.
func (h *CocoaGuiHost) runOnMain(fn func()) {
if cocoaOnMainThread() {
h.mainMu.Lock()
done := h.loopDone
h.mainMu.Unlock()
if done {
return
}
fn()
return
}
h.mainMu.Lock()
view := h.view
if view == 0 || h.loopDone {
h.mainMu.Unlock()
return
}
h.mainQueue = append(h.mainQueue, fn)
h.mainMu.Unlock()
cocoaPerformOnMain(view, cocoaSel.runQueued, false)
}
// runOnMainAndWait is runOnMain for work whose result the caller needs. It
// reports whether fn ran; see runOnMain for why the already-on-the-main-
// thread path has to check loopDone too (vtui #1571).
func (h *CocoaGuiHost) runOnMainAndWait(fn func()) bool {
if cocoaOnMainThread() {
h.mainMu.Lock()
done := h.loopDone
h.mainMu.Unlock()
if done {
return false
}
fn()
return true
}
done := make(chan struct{}, 1)
h.mainMu.Lock()
view := h.view
if view == 0 || h.loopDone {
h.mainMu.Unlock()
return false
}
h.mainQueue = append(h.mainQueue, func() {
fn()
done <- struct{}{}
})
h.mainMu.Unlock()
// Not waitUntilDone:YES: should the loop end before it gets to this,
// the caller would wait for ever.
cocoaPerformOnMain(view, cocoaSel.runQueued, false)
select {
case <-done:
return true
case <-time.After(2 * time.Second):
return false
}
}
// runQueued is vtuiRunQueued:, on the main thread.
func (h *CocoaGuiHost) runQueued() {
h.mainMu.Lock()
queue := h.mainQueue
h.mainQueue = nil
h.mainMu.Unlock()
for _, fn := range queue {
fn()
}
}
// Invalidate asks for a display pass, from any goroutine. The pass runs
// updateLayer on the main thread, which presents whatever frame is newest
// by then, so requests made while one is pending fold into it.
func (h *CocoaGuiHost) Invalidate() {
if !h.displayQueued.CompareAndSwap(false, true) {
return
}
h.runOnMain(func() {
h.displayQueued.Store(false)
if h.view != 0 {
h.view.Send(cocoaSel.setNeedsDisplay, true)
}
})
}
func (h *CocoaGuiHost) SetTitle(title string) {
full := WindowTitleWithBackend(title)
h.runOnMain(func() {
if h.window == 0 {
return
}
// A directory name off SMB/NFS/FUSE, or an OSC title set from a
// terminal session, is not guaranteed to be valid UTF-8;
// initWithUTF8String: returns nil for one that is not, and nil is
// not a title -setTitle: can take (vtui #1571).
s := cocoaNSString(strings.ToValidUTF8(full, "\uFFFD"))
if s == 0 {
return
}
h.window.Send(cocoaSel.setTitle, s)
s.Send(cocoaSel.release)
})
}
// ResizeGrid resizes the window to hold cols x rows cells. The new grid
// size reaches FrameManager the way a resize by hand does, through
// windowDidResize:.
//
// setContentSize: alone keeps the bottom-left corner of the window fixed,
// which would grow the window upward on screen; X11 and Win32 keep the top
// edge fixed instead, so the frame is recomputed by hand here to match
// (vtui #1571).
func (h *CocoaGuiHost) ResizeGrid(cols, rows int) {
if cols <= 0 || rows <= 0 {
return
}
h.runOnMain(func() {
if h.window == 0 || h.view == 0 {
return
}
oldFrame := objc.Send[cocoaRect](h.window, cocoaSel.frame)
oldContent := objc.Send[cocoaRect](h.view, cocoaSel.bounds).Size
// The chrome (title bar, border) around the content view keeps its
// own size; only the content grows or shrinks.
chromeW := oldFrame.Size.Width - oldContent.Width
chromeH := oldFrame.Size.Height - oldContent.Height
newSize := cocoaSize{
Width: float64(cols*h.cellW)/h.pointScale + chromeW,
Height: float64(rows*h.cellH)/h.pointScale + chromeH,
}
newFrame := cocoaRect{
Origin: cocoaPoint{
X: oldFrame.Origin.X,
Y: oldFrame.Origin.Y + oldFrame.Size.Height - newSize.Height,
},
Size: newSize,
}
h.window.Send(cocoaSel.setFrame, newFrame, true)
})
}
// ToggleMaximized zooms the window: to the largest size the screen allows,
// or back to the size it had before.
func (h *CocoaGuiHost) ToggleMaximized() bool {
h.mainMu.Lock()
done, window := h.loopDone, h.window
h.mainMu.Unlock()
if done || window == 0 {
return false
}
h.runOnMain(func() {
if h.window != 0 {
h.window.Send(cocoaSel.zoom, objc.ID(0))
}
})
return true
}
// WindowPosition returns the top-left corner of the window frame in points,
// measured from the top-left corner of the main display.
func (h *CocoaGuiHost) WindowPosition() (x, y int, ok bool) {
type position struct{ x, y int }
result := make(chan position, 1)
h.runOnMainAndWait(func() {
if h.window == 0 {
return
}
f := objc.Send[cocoaRect](h.window, cocoaSel.frame)
top := cocoaPrimaryScreenTop()
result <- position{int(math.Round(f.Origin.X)), int(math.Round(top - (f.Origin.Y + f.Size.Height)))}
})
select {
case p := <-result:
return p.x, p.y, true
default:
return 0, 0, false
}
}
// SetWindowPosition moves the window, in the coordinates WindowPosition
// reports. Called during setup, it runs before the window is first shown.
func (h *CocoaGuiHost) SetWindowPosition(x, y int) {
h.runOnMain(func() {
if h.window == 0 {
return
}
h.window.Send(cocoaSel.setFrameTopLeftPoint, cocoaPoint{X: float64(x), Y: cocoaPrimaryScreenTop() - float64(y)})
})
}
// PostQuit ends the main loop once FrameManager has finished.
func (h *CocoaGuiHost) PostQuit() {
h.mu.Lock()
h.closed = true
h.mu.Unlock()
h.runOnMain(cocoaStopApp)
}
// sendEvent hands an event to FrameManager without ever blocking the main
// thread. A pointer move that finds the queue full is dropped, the next one
// carries the position anyway.
func (h *CocoaGuiHost) sendEvent(ev *vtinput.InputEvent) {
h.mu.Lock()
var ch chan *vtinput.InputEvent
if !h.closed && h.reader != nil {
ch = h.reader.EventChan
}
h.mu.Unlock()
if ch == nil {
return
}
// The reader closes its channel as FrameManager.Run returns, and a
// callback can still be on its way here at that moment.
defer func() { _ = recover() }()
select {
case ch <- ev:
default:
if ev.Type == vtinput.MouseEventType && ev.MouseEventFlags&vtinput.MouseMoved != 0 {
return
}
DebugLog("COCOA: input queue full, dropped %s", ev.String())
}
}
// updateGridSize recomputes the grid from the size of the view, after a
// resize by hand, a zoom or ResizeGrid.
func (h *CocoaGuiHost) updateGridSize() {
if h.view == 0 {
return
}
b := objc.Send[cocoaRect](h.view, cocoaSel.bounds)
cols, rows := cocoaGridForPixels(
int(math.Round(b.Size.Width*h.pointScale)), int(math.Round(b.Size.Height*h.pointScale)),
h.cellW, h.cellH)
h.mu.Lock()
changed := cols != h.cols || rows != h.rows
h.cols, h.rows = cols, rows
h.mu.Unlock()
if changed {
h.sendEvent(&vtinput.InputEvent{Type: vtinput.ResizeEventType})
}
// The canvas follows the view even when the grid does not: the margin
// right of and below the grid has to be repainted.
h.view.Send(cocoaSel.setNeedsDisplay, true)
}
// presentFrame is updateLayer. It lays the newest frame onto a bitmap the
// size of the view in pixels, black where the grid does not reach, and
// makes a CGImage of it the layer's contents. The bitmap is kept between
// frames; CGBitmapContextCreateImage copies it (copy-on-write), so the
// image the layer holds is not disturbed by the next frame being written.
func (h *CocoaGuiHost) presentFrame(view objc.ID) {
layer := view.Send(cocoaSel.layer)
if layer == 0 {
return
}
b := objc.Send[cocoaRect](view, cocoaSel.bounds)
w := int(math.Round(b.Size.Width * h.pointScale))
ht := int(math.Round(b.Size.Height * h.pointScale))
if w <= 0 || ht <= 0 {
return
}
if h.bitmap == 0 || h.bitmapW != w || h.bitmapH != ht {
if h.bitmap != 0 {
cgContextRelease(h.bitmap)
}
h.bitmap = cgBitmapContextCreate(nil, uintptr(w), uintptr(ht), 8, 0, cocoaRT.colorSpace, cgImageAlphaNoneSkipLast)
h.bitmapW, h.bitmapH = w, ht
if h.bitmap == 0 {
DebugLog("COCOA: CGBitmapContextCreate failed for %dx%d", w, ht)
return
}
}
data := cgBitmapContextGetData(h.bitmap)