From 5fe3900efa30bb0ad87d1831a6a5e9f8b16e2d2e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Jan 2024 15:02:32 -0800 Subject: [PATCH 1/7] core: Apple Emoji should be loaded on any darwin, not just macos It is available on iOS too. --- src/Surface.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index bb38b0ad7..487e25390 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -429,7 +429,7 @@ pub fn init( // people add other emoji fonts to their system, we always want to // prefer the official one. Users can override this by explicitly // specifying a font-family for emoji. - if (comptime builtin.os.tag == .macos) apple_emoji: { + if (comptime builtin.target.isDarwin()) apple_emoji: { const disco = group.discover orelse break :apple_emoji; var disco_it = try disco.discover(alloc, .{ .family = "Apple Color Emoji", @@ -442,7 +442,7 @@ pub fn init( // Emoji fallback. We don't include this on Mac since Mac is expected // to always have the Apple Emoji available on the system. - if (builtin.os.tag != .macos or font.Discover == void) { + if (comptime !builtin.target.isDarwin() or font.Discover == void) { _ = try group.addFace( .regular, .{ .fallback_loaded = try font.Face.init(font_lib, face_emoji_ttf, group.faceOptions()) }, From 26e6e8cec87ff57acd3a02e6f55ffa5c125f60d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Jan 2024 15:03:03 -0800 Subject: [PATCH 2/7] apprt/embedded: add iOS platform with uivew --- include/ghostty.h | 6 ++++++ src/apprt/embedded.zig | 18 ++++++++++++++++++ src/renderer/Metal.zig | 1 + 3 files changed, 25 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 9035d9c0a..a6283170d 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -33,6 +33,7 @@ typedef void *ghostty_inspector_t; typedef enum { GHOSTTY_PLATFORM_INVALID, GHOSTTY_PLATFORM_MACOS, + GHOSTTY_PLATFORM_IOS, } ghostty_platform_e; typedef enum { @@ -358,8 +359,13 @@ typedef struct { void *nsview; } ghostty_platform_macos_s; +typedef struct { + void *uiview; +} ghostty_platform_ios_s; + typedef union { ghostty_platform_macos_s macos; + ghostty_platform_ios_s ios; } ghostty_platform_u; typedef struct { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index a622fccc4..6b631de17 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -234,6 +234,7 @@ pub const App = struct { /// Platform-specific configuration for libghostty. pub const Platform = union(PlatformTag) { macos: MacOS, + ios: IOS, // If our build target for libghostty is not darwin then we do // not include macos support at all. @@ -242,12 +243,21 @@ pub const Platform = union(PlatformTag) { nsview: objc.Object, } else void; + pub const IOS = if (builtin.target.isDarwin()) struct { + /// The view to render the surface on. + uiview: objc.Object, + } else void; + // The C ABI compatible version of this union. The tag is expected // to be stored elsewhere. pub const C = extern union { macos: extern struct { nsview: ?*anyopaque, }, + + ios: extern struct { + uiview: ?*anyopaque, + }, }; /// Initialize a Platform a tag and configuration from the C ABI. @@ -260,6 +270,13 @@ pub const Platform = union(PlatformTag) { break :macos error.NSViewMustBeSet); break :macos .{ .macos = .{ .nsview = nsview } }; } else error.UnsupportedPlatform, + + .ios => if (IOS != void) ios: { + const config = c_platform.ios; + const uiview = objc.Object.fromId(config.uiview orelse + break :ios error.UIViewMustBeSet); + break :ios .{ .ios = .{ .uiview = uiview } }; + } else error.UnsupportedPlatform, }; } }; @@ -269,6 +286,7 @@ pub const PlatformTag = enum(c_int) { // from the C API. macos = 1, + ios = 2, }; pub const Surface = struct { diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 7a689f6be..5232d0e04 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -468,6 +468,7 @@ pub fn finalizeSurfaceInit(self: *const Metal, surface: *apprt.Surface) !void { apprt.embedded => .{ .view = switch (surface.platform) { .macos => |v| v.nsview, + .ios => |v| v.uiview, }, .scaleFactor = @floatCast(surface.content_scale.x), }, From 95f729a5fc3ffdba671ecb8f26ad8f07f1d12c71 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Jan 2024 15:36:06 -0800 Subject: [PATCH 3/7] renderer/metal: only set wantsLayer for AppKit --- src/renderer/Metal.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 5232d0e04..9562e8d3a 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -476,9 +476,13 @@ pub fn finalizeSurfaceInit(self: *const Metal, surface: *apprt.Surface) !void { else => @compileError("unsupported apprt for metal"), }; - // Make our view layer-backed with our Metal layer - info.view.setProperty("layer", self.swapchain.value); - info.view.setProperty("wantsLayer", true); + // Make our view layer-backed with our Metal layer. On iOS views are + // always layer backed so we don't need to do this. But on iOS the + // caller MUST be sure to set the layerClass to CAMetalLayer. + if (comptime builtin.os.tag == .macos) { + info.view.setProperty("layer", self.swapchain.value); + info.view.setProperty("wantsLayer", true); + } // Ensure that our metal layer has a content scale set to match the // scale factor of the window. This avoids magnification issues leading From 344c2db097bcbda106dd44ef80026532af4ed3ba Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Jan 2024 18:50:22 -0800 Subject: [PATCH 4/7] renderer/metal: initialize layer in init, handle iOS layer --- src/Surface.zig | 1 + src/renderer/Metal.zig | 129 ++++++++++++++++++++++----------------- src/renderer/Options.zig | 3 + 3 files changed, 78 insertions(+), 55 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 487e25390..ada0dfd7f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -488,6 +488,7 @@ pub fn init( .balance = config.@"window-padding-balance", }, .surface_mailbox = .{ .surface = self, .app = app_mailbox }, + .rt_surface = rt_surface, }); errdefer renderer_impl.deinit(); diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index 9562e8d3a..65032ab50 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -115,7 +115,7 @@ buf_instance: InstanceBuffer, // MTLBuffer /// Metal objects device: objc.Object, // MTLDevice queue: objc.Object, // MTLCommandQueue -swapchain: objc.Object, // CAMetalLayer +layer: objc.Object, // CAMetalLayer texture_greyscale: objc.Object, // MTLTexture texture_color: objc.Object, // MTLTexture @@ -262,20 +262,77 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { defer arena.deinit(); const arena_alloc = arena.allocator(); + const ViewInfo = struct { + view: objc.Object, + scaleFactor: f64, + }; + + // Get the metadata about our underlying view that we'll be rendering to. + const info: ViewInfo = switch (apprt.runtime) { + apprt.glfw => info: { + // Everything in glfw is window-oriented so we grab the backing + // window, then derive everything from that. + const nswindow = objc.Object.fromId(glfwNative.getCocoaWindow( + options.rt_surface.window, + ).?); + + const contentView = objc.Object.fromId( + nswindow.getProperty(?*anyopaque, "contentView").?, + ); + const scaleFactor = nswindow.getProperty( + macos.graphics.c.CGFloat, + "backingScaleFactor", + ); + + break :info .{ + .view = contentView, + .scaleFactor = scaleFactor, + }; + }, + + apprt.embedded => .{ + .scaleFactor = @floatCast(options.rt_surface.content_scale.x), + .view = switch (options.rt_surface.platform) { + .macos => |v| v.nsview, + .ios => |v| v.uiview, + }, + }, + + else => @compileError("unsupported apprt for metal"), + }; + // Initialize our metal stuff const device = objc.Object.fromId(mtl.MTLCreateSystemDefaultDevice()); const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{}); - const swapchain = swapchain: { - const CAMetalLayer = objc.getClass("CAMetalLayer").?; - const swapchain = CAMetalLayer.msgSend(objc.Object, objc.sel("layer"), .{}); - swapchain.setProperty("device", device.value); - swapchain.setProperty("opaque", options.config.background_opacity >= 1); - // disable v-sync - swapchain.setProperty("displaySyncEnabled", false); + // Get our CAMetalLayer + const layer = switch (builtin.os.tag) { + .macos => layer: { + const CAMetalLayer = objc.getClass("CAMetalLayer").?; + break :layer CAMetalLayer.msgSend(objc.Object, objc.sel("layer"), .{}); + }, - break :swapchain swapchain; + // iOS is always layer-backed so we don't need to do anything here. + .ios => info.view.getProperty(objc.Object, "layer"), + + else => @compileError("unsupported target for Metal"), }; + layer.setProperty("device", device.value); + layer.setProperty("opaque", options.config.background_opacity >= 1); + layer.setProperty("displaySyncEnabled", false); // disable v-sync + + // Make our view layer-backed with our Metal layer. On iOS views are + // always layer backed so we don't need to do this. But on iOS the + // caller MUST be sure to set the layerClass to CAMetalLayer. + if (comptime builtin.os.tag == .macos) { + info.view.setProperty("layer", layer.value); + info.view.setProperty("wantsLayer", true); + } + + // Ensure that our metal layer has a content scale set to match the + // scale factor of the window. This avoids magnification issues leading + // to blurry rendering. + layer.setProperty("contentsScale", info.scaleFactor); // Get our cell metrics based on a regular font ascii 'M'. Why 'M'? // Doesn't matter, any normal ASCII will do we're just trying to make @@ -401,7 +458,7 @@ pub fn init(alloc: Allocator, options: renderer.Options) !Metal { // Metal stuff .device = device, .queue = queue, - .swapchain = swapchain, + .layer = layer, .texture_greyscale = texture_greyscale, .texture_color = texture_color, .custom_shader_state = custom_shader_state, @@ -445,50 +502,12 @@ pub fn deinit(self: *Metal) void { /// This is called just prior to spinning up the renderer thread for /// final main thread setup requirements. -pub fn finalizeSurfaceInit(self: *const Metal, surface: *apprt.Surface) !void { - const Info = struct { - view: objc.Object, - scaleFactor: f64, - }; +pub fn finalizeSurfaceInit(self: *Metal, surface: *apprt.Surface) !void { + _ = self; + _ = surface; - // Get the view and scale factor for our surface. - const info: Info = switch (apprt.runtime) { - apprt.glfw => info: { - // Everything in glfw is window-oriented so we grab the backing - // window, then derive everything from that. - const nswindow = objc.Object.fromId(glfwNative.getCocoaWindow(surface.window).?); - const contentView = objc.Object.fromId(nswindow.getProperty(?*anyopaque, "contentView").?); - const scaleFactor = nswindow.getProperty(macos.graphics.c.CGFloat, "backingScaleFactor"); - break :info .{ - .view = contentView, - .scaleFactor = scaleFactor, - }; - }, - - apprt.embedded => .{ - .view = switch (surface.platform) { - .macos => |v| v.nsview, - .ios => |v| v.uiview, - }, - .scaleFactor = @floatCast(surface.content_scale.x), - }, - - else => @compileError("unsupported apprt for metal"), - }; - - // Make our view layer-backed with our Metal layer. On iOS views are - // always layer backed so we don't need to do this. But on iOS the - // caller MUST be sure to set the layerClass to CAMetalLayer. - if (comptime builtin.os.tag == .macos) { - info.view.setProperty("layer", self.swapchain.value); - info.view.setProperty("wantsLayer", true); - } - - // Ensure that our metal layer has a content scale set to match the - // scale factor of the window. This avoids magnification issues leading - // to blurry rendering. - const layer = info.view.getProperty(objc.Object, "layer"); - layer.setProperty("contentsScale", info.scaleFactor); + // Metal doesn't have to do anything here. OpenGL has to do things + // like release the context but Metal doesn't have anything like that. } /// Callback called by renderer.Thread when it begins. @@ -743,7 +762,7 @@ pub fn drawFrame(self: *Metal, surface: *apprt.Surface) !void { defer pool.deinit(); // Get our drawable (CAMetalDrawable) - const drawable = self.swapchain.msgSend(objc.Object, objc.sel("nextDrawable"), .{}); + const drawable = self.layer.msgSend(objc.Object, objc.sel("nextDrawable"), .{}); // Get our screen texture. If we don't have a dedicated screen texture // then we just use the drawable texture. @@ -1420,7 +1439,7 @@ pub fn setScreenSize( const padded_dim = dim.subPadding(padding); // Set the size of the drawable surface to the bounds - self.swapchain.setProperty("drawableSize", macos.graphics.Size{ + self.layer.setProperty("drawableSize", macos.graphics.Size{ .width = @floatFromInt(dim.width), .height = @floatFromInt(dim.height), }); diff --git a/src/renderer/Options.zig b/src/renderer/Options.zig index e7b647924..c951eacd1 100644 --- a/src/renderer/Options.zig +++ b/src/renderer/Options.zig @@ -18,6 +18,9 @@ padding: Padding, /// once the thread has started and should not be used outside of the thread. surface_mailbox: apprt.surface.Mailbox, +/// The apprt surface. +rt_surface: *apprt.Surface, + pub const Padding = struct { // Explicit padding options, in pixels. The surface thread is // expected to convert points to pixels for a given DPI. From 487c22011c60f2c6984e8cd5f7ba876350f626a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Jan 2024 19:27:43 -0800 Subject: [PATCH 5/7] macos: make SurfaceView cross-platform! --- macos/Ghostty.xcodeproj/project.pbxproj | 18 + macos/Sources/App/iOS/iOSApp.swift | 16 +- macos/Sources/Ghostty/SurfaceView.swift | 1018 +---------------- .../Sources/Ghostty/SurfaceView_AppKit.swift | 955 ++++++++++++++++ macos/Sources/Ghostty/SurfaceView_UIKit.swift | 72 ++ macos/Sources/Helpers/CrossKit.swift | 54 + 6 files changed, 1153 insertions(+), 980 deletions(-) create mode 100644 macos/Sources/Ghostty/SurfaceView_AppKit.swift create mode 100644 macos/Sources/Ghostty/SurfaceView_UIKit.swift create mode 100644 macos/Sources/Helpers/CrossKit.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 7322c3a08..458feec34 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -21,6 +21,12 @@ A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; }; A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */; }; A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */; }; + A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */; }; + A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */; }; + A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */; }; + A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */; }; + A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; }; + A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; }; A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */; }; A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; }; A53D0C8E2B53B0EA00305CE6 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; @@ -75,6 +81,9 @@ A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = ""; }; A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateDelegate.swift; sourceTree = ""; }; A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Input.swift; sourceTree = ""; }; + A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_UIKit.swift; sourceTree = ""; }; + A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossKit.swift; sourceTree = ""; }; + A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_AppKit.swift; sourceTree = ""; }; A53426342A7DA53D00EBB7A2 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; @@ -173,6 +182,7 @@ isa = PBXGroup; children = ( A5CEAFFE29C2410700646FDA /* Backport.swift */, + A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, @@ -236,6 +246,8 @@ children = ( A55B7BB729B6F53A0055DE60 /* Package.swift */, A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */, + A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */, + A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */, A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */, A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */, A514C8D52B54A16400493A16 /* Ghostty.Config.swift */, @@ -457,8 +469,10 @@ A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, + A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, + A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */, A59630A22AF0415000D64628 /* Ghostty.TerminalSplit.swift in Sources */, @@ -487,8 +501,12 @@ files = ( A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */, A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */, + A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */, + A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */, + A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */, A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */, + A5333E242B5A22D9008AEFF7 /* Ghostty.Shell.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macos/Sources/App/iOS/iOSApp.swift b/macos/Sources/App/iOS/iOSApp.swift index bf581d6cd..50c115293 100644 --- a/macos/Sources/App/iOS/iOSApp.swift +++ b/macos/Sources/App/iOS/iOSApp.swift @@ -6,13 +6,21 @@ struct Ghostty_iOSApp: App { var body: some Scene { WindowGroup { - iOS_ContentView() + iOS_GhosttyTerminal() .environmentObject(ghostty_app) } } } -struct iOS_ContentView: View { +struct iOS_GhosttyTerminal: View { + @EnvironmentObject private var ghostty_app: Ghostty.App + + var body: some View { + Ghostty.Terminal() + } +} + +struct iOS_GhosttyInitView: View { @EnvironmentObject private var ghostty_app: Ghostty.App var body: some View { @@ -27,7 +35,3 @@ struct iOS_ContentView: View { .padding() } } - -#Preview { - iOS_ContentView() -} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 2d320eab1..be7cd9e6e 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -66,14 +66,18 @@ extension Ghostty { // We use these notifications to determine when the window our surface is // attached to is or is not focused. let pubBecomeFocused = center.publisher(for: Notification.didBecomeFocusedSurface, object: surfaceView) + + #if canImport(AppKit) let pubBecomeKey = center.publisher(for: NSWindow.didBecomeKeyNotification) let pubResign = center.publisher(for: NSWindow.didResignKeyNotification) + #endif Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size) .focused($surfaceFocus) .focusedValue(\.ghosttySurfaceTitle, surfaceView.title) .focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) + #if canImport(AppKit) .onReceive(pubBecomeKey) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } @@ -86,6 +90,23 @@ extension Ghostty { windowFocus = false } } + .onDrop(of: [.fileURL], isTargeted: nil) { providers in + providers.forEach { provider in + _ = provider.loadObject(ofClass: URL.self) { url, _ in + guard let url = url else { return } + let path = Shell.escape(url.path) + DispatchQueue.main.async { + surfaceView.insertText( + path, + replacementRange: NSMakeRange(0, 0) + ) + } + } + } + + return true + } + #endif .onReceive(pubBecomeFocused) { notification in // We only want to run this on older macOS versions where the .focused // method doesn't work properly. See the dispatch of this notification @@ -125,22 +146,6 @@ extension Ghostty { // I don't know how older macOS versions behave but Ghostty only // supports back to macOS 12 so its moot. } - .onDrop(of: [.fileURL], isTargeted: nil) { providers in - providers.forEach { provider in - _ = provider.loadObject(ofClass: URL.self) { url, _ in - guard let url = url else { return } - let path = Shell.escape(url.path) - DispatchQueue.main.async { - surfaceView.insertText( - path, - replacementRange: NSMakeRange(0, 0) - ) - } - } - } - - return true - } } .ghosttySurfaceView(surfaceView) @@ -221,7 +226,7 @@ extension Ghostty { /// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible /// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to /// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with. - struct Surface: NSViewRepresentable { + struct Surface: OSViewRepresentable { /// The view to render for the terminal surface. let view: SurfaceView @@ -240,14 +245,14 @@ extension Ghostty { /// The best approach is to wrap this view in a GeometryReader and pass in the geo.size. let size: CGSize - func makeNSView(context: Context) -> SurfaceView { + func makeOSView(context: Context) -> SurfaceView { // We need the view as part of the state to be created previously because // the view is sent to the Ghostty API so that it can manipulate it // directly since we draw on a render thread. return view; } - func updateNSView(_ view: SurfaceView, context: Context) { + func updateOSView(_ view: SurfaceView, context: Context) { view.focusDidChange(hasFocus) view.sizeDidChange(size) } @@ -261,7 +266,7 @@ extension Ghostty { /// Explicit working directory to set var workingDirectory: String? = nil - + /// Explicit command to set var command: String? = nil @@ -277,13 +282,28 @@ extension Ghostty { /// in the returned struct is only valid as long as this struct is retained. func ghosttyConfig(view: SurfaceView) -> ghostty_surface_config_s { var config = ghostty_surface_config_new() + config.userdata = Unmanaged.passUnretained(view).toOpaque() + #if os(macOS) config.platform_tag = GHOSTTY_PLATFORM_MACOS config.platform = ghostty_platform_u(macos: ghostty_platform_macos_s( nsview: Unmanaged.passUnretained(view).toOpaque() )) - config.userdata = Unmanaged.passUnretained(view).toOpaque() config.scale_factor = NSScreen.main!.backingScaleFactor - + + #elseif os(iOS) + config.platform_tag = GHOSTTY_PLATFORM_IOS + config.platform = ghostty_platform_u(ios: ghostty_platform_ios_s( + uiview: Unmanaged.passUnretained(view).toOpaque() + )) + // Note that UIScreen.main is deprecated and we're supposed to get the + // screen through the view hierarchy instead. This means that we should + // probably set this to some default, then modify the scale factor through + // libghostty APIs when a UIView is attached to a window/scene. TODO. + config.scale_factor = UIScreen.main.scale + #else + #error("unsupported target") + #endif + if let fontSize = fontSize { config.font_size = fontSize } if let workingDirectory = workingDirectory { config.working_directory = (workingDirectory as NSString).utf8String @@ -295,956 +315,6 @@ extension Ghostty { return config } } - - // MARK: - Surface View - - /// The NSView implementation for a terminal surface. - class SurfaceView: NSView, NSTextInputClient, ObservableObject { - /// Unique ID per surface - let uuid: UUID - - // The current title of the surface as defined by the pty. This can be - // changed with escape codes. This is public because the callbacks go - // to the app level and it is set from there. - @Published var title: String = "👻" - - // The cell size of this surface. This is set by the core when the - // surface is first created and any time the cell size changes (i.e. - // when the font size changes). This is used to allow windows to be - // resized in discrete steps of a single cell. - @Published var cellSize: NSSize = .zero - - // The health state of the surface. This currently only reflects the - // renderer health. In the future we may want to make this an enum. - @Published var healthy: Bool = true - - // Any error while initializing the surface. - @Published var error: Error? = nil - - // An initial size to request for a window. This will only affect - // then the view is moved to a new window. - var initialSize: NSSize? = nil - - // Returns true if quit confirmation is required for this surface to - // exit safely. - var needsConfirmQuit: Bool { - guard let surface = self.surface else { return false } - return ghostty_surface_needs_confirm_quit(surface) - } - - /// Returns the pwd of the surface if it has one. - var pwd: String? { - guard let surface = self.surface else { return nil } - let v = String(unsafeUninitializedCapacity: 1024) { - Int(ghostty_surface_pwd(surface, $0.baseAddress, UInt($0.count))) - } - - if (v.count == 0) { return nil } - return v - } - - // Returns the inspector instance for this surface, or nil if the - // surface has been closed. - var inspector: ghostty_inspector_t? { - guard let surface = self.surface else { return nil } - return ghostty_surface_inspector(surface) - } - - // True if the inspector should be visible - @Published var inspectorVisible: Bool = false { - didSet { - if (oldValue && !inspectorVisible) { - guard let surface = self.surface else { return } - ghostty_inspector_free(surface) - } - } - } - - // Notification identifiers associated with this surface - var notificationIdentifiers: Set = [] - - private(set) var surface: ghostty_surface_t? - private var markedText: NSMutableAttributedString - private var mouseEntered: Bool = false - private(set) var focused: Bool = true - private var cursor: NSCursor = .iBeam - private var cursorVisible: CursorVisibility = .visible - - // This is set to non-null during keyDown to accumulate insertText contents - private var keyTextAccumulator: [String]? = nil - - // We need to support being a first responder so that we can get input events - override var acceptsFirstResponder: Bool { return true } - - // I don't think we need this but this lets us know we should redraw our layer - // so we'll use that to tell ghostty to refresh. - override var wantsUpdateLayer: Bool { return true } - - // State machine for mouse cursor visibility because every call to - // NSCursor.hide/unhide must be balanced. - enum CursorVisibility { - case visible - case hidden - case pendingVisible - case pendingHidden - } - - init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { - self.markedText = NSMutableAttributedString() - self.uuid = uuid ?? .init() - - // Initialize with some default frame size. The important thing is that this - // is non-zero so that our layer bounds are non-zero so that our renderer - // can do SOMETHING. - super.init(frame: NSMakeRect(0, 0, 800, 600)) - - // Before we initialize the surface we want to register our notifications - // so there is no window where we can't receive them. - let center = NotificationCenter.default - center.addObserver( - self, - selector: #selector(onUpdateRendererHealth), - name: Ghostty.Notification.didUpdateRendererHealth, - object: self) - - // Setup our surface. This will also initialize all the terminal IO. - let surface_cfg = baseConfig ?? SurfaceConfiguration() - var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) - guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { - self.error = AppError.surfaceCreateError - return - } - self.surface = surface; - - // Setup our tracking area so we get mouse moved events - updateTrackingAreas() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) is not supported for this view") - } - - deinit { - // Remove all of our notificationcenter subscriptions - let center = NotificationCenter.default - center.removeObserver(self) - - // Whenever the surface is removed, we need to note that our restorable - // state is invalid to prevent the surface from being restored. - invalidateRestorableState() - - trackingAreas.forEach { removeTrackingArea($0) } - - // mouseExited is not called by AppKit one last time when the view - // closes so we do it manually to ensure our NSCursor state remains - // accurate. - if (mouseEntered) { - mouseExited(with: NSEvent()) - } - - guard let surface = self.surface else { return } - ghostty_surface_free(surface) - } - - /// Close the surface early. This will free the associated Ghostty surface and the view will - /// no longer render. The view can never be used again. This is a way for us to free the - /// Ghostty resources while references may still be held to this view. I've found that SwiftUI - /// tends to hold this view longer than it should so we free the expensive stuff explicitly. - func close() { - // Remove any notifications associated with this surface - let identifiers = Array(self.notificationIdentifiers) - UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) - - guard let surface = self.surface else { return } - ghostty_surface_free(surface) - self.surface = nil - } - - func focusDidChange(_ focused: Bool) { - guard let surface = self.surface else { return } - ghostty_surface_set_focus(surface, focused) - } - - func sizeDidChange(_ size: CGSize) { - guard let surface = self.surface else { return } - - // Ghostty wants to know the actual framebuffer size... It is very important - // here that we use "size" and NOT the view frame. If we're in the middle of - // an animation (i.e. a fullscreen animation), the frame will not yet be updated. - // The size represents our final size we're going for. - let scaledSize = self.convertToBacking(size) - ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height)) - - // Frame changes do not always call mouseEntered/mouseExited, so we do some - // calculations ourself to call those events. - if let window = self.window { - let mouseScreen = NSEvent.mouseLocation - let mouseWindow = window.convertPoint(fromScreen: mouseScreen) - let mouseView = self.convert(mouseWindow, from: nil) - let isEntered = self.isMousePoint(mouseView, in: bounds) - if (isEntered) { - mouseEntered(with: NSEvent()) - } else { - mouseExited(with: NSEvent()) - } - } else { - // If we don't have a window, then our mouse can NOT be in our view. - // When the window comes back, I believe this event fires again so - // we'll get a mouseEntered. - mouseExited(with: NSEvent()) - } - } - - func setCursorShape(_ shape: ghostty_mouse_shape_e) { - switch (shape) { - case GHOSTTY_MOUSE_SHAPE_DEFAULT: - cursor = .arrow - - case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: - cursor = .contextualMenu - - case GHOSTTY_MOUSE_SHAPE_TEXT: - cursor = .iBeam - - case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: - cursor = .crosshair - - case GHOSTTY_MOUSE_SHAPE_GRAB: - cursor = .openHand - - case GHOSTTY_MOUSE_SHAPE_GRABBING: - cursor = .closedHand - - case GHOSTTY_MOUSE_SHAPE_POINTER: - cursor = .pointingHand - - case GHOSTTY_MOUSE_SHAPE_W_RESIZE: - cursor = .resizeLeft - - case GHOSTTY_MOUSE_SHAPE_E_RESIZE: - cursor = .resizeRight - - case GHOSTTY_MOUSE_SHAPE_N_RESIZE: - cursor = .resizeUp - - case GHOSTTY_MOUSE_SHAPE_S_RESIZE: - cursor = .resizeDown - - case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: - cursor = .resizeUpDown - - case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: - cursor = .resizeLeftRight - - case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: - cursor = .iBeamCursorForVerticalLayout - - case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: - cursor = .operationNotAllowed - - default: - // We ignore unknown shapes. - return - } - - // Set our cursor immediately if our mouse is over our window - if (mouseEntered) { cursorUpdate(with: NSEvent()) } - if let window = self.window { - window.invalidateCursorRects(for: self) - } - } - - func setCursorVisibility(_ visible: Bool) { - switch (cursorVisible) { - case .visible: - // If we want to be visible, do nothing. If we want to be hidden - // enter the pending state. - if (visible) { return } - cursorVisible = .pendingHidden - - case .hidden: - // If we want to be hidden, do nothing. If we want to be visible - // enter the pending state. - if (!visible) { return } - cursorVisible = .pendingVisible - - case .pendingVisible: - // If we want to be visible, do nothing because we're already pending. - // If we want to be hidden, we're already hidden so reset state. - if (visible) { return } - cursorVisible = .hidden - - case .pendingHidden: - // If we want to be hidden, do nothing because we're pending that switch. - // If we want to be visible, we're already visible so reset state. - if (!visible) { return } - cursorVisible = .visible - } - - if (mouseEntered) { - cursorUpdate(with: NSEvent()) - } - } - - // MARK: - Notifications - - @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { - guard let healthAny = notification.userInfo?["health"] else { return } - guard let health = healthAny as? ghostty_renderer_health_e else { return } - healthy = health == GHOSTTY_RENDERER_HEALTH_OK - } - - // MARK: - NSView - - override func viewDidMoveToWindow() { - // Set our background blur if requested - setWindowBackgroundBlur(window) - } - - /// This function sets the window background to blur if it is configured on the surface. - private func setWindowBackgroundBlur(_ targetWindow: NSWindow?) { - // Surface must desire transparency - guard let surface = self.surface, - ghostty_surface_transparent(surface) else { return } - - // Our target should always be our own view window - guard let target = targetWindow, - let window = self.window, - target == window else { return } - - // If our window is not visible, then delay this. This is possible specifically - // during state restoration but probably in other scenarios as well. To delay, - // we just loop directly on the dispatch queue. - guard window.isVisible else { - // Weak window so that if the window changes or is destroyed we aren't holding a ref - DispatchQueue.main.async { [weak self, weak window] in self?.setWindowBackgroundBlur(window) } - return - } - - // Set the window transparency settings - window.isOpaque = false - window.hasShadow = false - window.backgroundColor = .clear - - // If we have a blur, set the blur - ghostty_set_window_background_blur(surface, Unmanaged.passUnretained(window).toOpaque()) - } - - override func becomeFirstResponder() -> Bool { - let result = super.becomeFirstResponder() - if (result) { focused = true } - return result - } - - override func resignFirstResponder() -> Bool { - let result = super.resignFirstResponder() - - // We sometimes call this manually (see SplitView) as a way to force us to - // yield our focus state. - if (result) { - focusDidChange(false) - focused = false - } - - return result - } - - override func updateTrackingAreas() { - // To update our tracking area we just recreate it all. - trackingAreas.forEach { removeTrackingArea($0) } - - // This tracking area is across the entire frame to notify us of mouse movements. - addTrackingArea(NSTrackingArea( - rect: frame, - options: [ - .mouseEnteredAndExited, - .mouseMoved, - - // Only send mouse events that happen in our visible (not obscured) rect - .inVisibleRect, - - // We want active always because we want to still send mouse reports - // even if we're not focused or key. - .activeAlways, - ], - owner: self, - userInfo: nil)) - } - - override func resetCursorRects() { - discardCursorRects() - addCursorRect(frame, cursor: self.cursor) - } - - override func viewDidChangeBackingProperties() { - guard let surface = self.surface else { return } - - // Detect our X/Y scale factor so we can update our surface - let fbFrame = self.convertToBacking(self.frame) - let xScale = fbFrame.size.width / self.frame.size.width - let yScale = fbFrame.size.height / self.frame.size.height - ghostty_surface_set_content_scale(surface, xScale, yScale) - - // When our scale factor changes, so does our fb size so we send that too - ghostty_surface_set_size(surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height)) - } - - override func updateLayer() { - guard let surface = self.surface else { return } - ghostty_surface_refresh(surface); - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - // "Override this method in a subclass to allow instances to respond to - // click-through. This allows the user to click on a view in an inactive - // window, activating the view with one click, instead of clicking first - // to make the window active and then clicking the view." - return true - } - - override func mouseDown(with event: NSEvent) { - guard let surface = self.surface else { return } - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) - } - - override func mouseUp(with event: NSEvent) { - guard let surface = self.surface else { return } - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) - } - - override func otherMouseDown(with event: NSEvent) { - guard let surface = self.surface else { return } - guard event.buttonNumber == 2 else { return } - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mods) - } - - override func otherMouseUp(with event: NSEvent) { - guard let surface = self.surface else { return } - guard event.buttonNumber == 2 else { return } - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mods) - } - - - override func rightMouseDown(with event: NSEvent) { - guard let surface = self.surface else { return } - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) - } - - override func rightMouseUp(with event: NSEvent) { - guard let surface = self.surface else { return } - let mods = Ghostty.ghosttyMods(event.modifierFlags) - ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) - } - - override func mouseMoved(with event: NSEvent) { - guard let surface = self.surface else { return } - - // Convert window position to view position. Note (0, 0) is bottom left. - let pos = self.convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y) - - } - - override func mouseDragged(with event: NSEvent) { - self.mouseMoved(with: event) - } - - override func mouseEntered(with event: NSEvent) { - // For reasons unknown (Cocoaaaaaaaaa), mouseEntered is called - // multiple times in an unbalanced way with mouseExited when a new - // tab is created. In this scenario, we only want to process our - // callback once since this is stateful and we expect balancing. - if (mouseEntered) { return } - - mouseEntered = true - - // Update our cursor when we enter so we fully process our - // cursorVisible state. - cursorUpdate(with: NSEvent()) - } - - override func mouseExited(with event: NSEvent) { - // See mouseEntered - if (!mouseEntered) { return } - - mouseEntered = false - - // If the mouse is currently hidden, we want to show it when we exit - // this view. We go through the cursorVisible dance so that only - // cursorUpdate manages cursor state. - if (cursorVisible == .hidden) { - cursorVisible = .pendingVisible - cursorUpdate(with: NSEvent()) - assert(cursorVisible == .visible) - - // We set the state to pending hidden again for the next time - // we enter. - cursorVisible = .pendingHidden - } - } - - override func scrollWheel(with event: NSEvent) { - guard let surface = self.surface else { return } - - // Builds up the "input.ScrollMods" bitmask - var mods: Int32 = 0 - - var x = event.scrollingDeltaX - var y = event.scrollingDeltaY - if event.hasPreciseScrollingDeltas { - mods = 1 - - // We do a 2x speed multiplier. This is subjective, it "feels" better to me. - x *= 2; - y *= 2; - - // TODO(mitchellh): do we have to scale the x/y here by window scale factor? - } - - // Determine our momentum value - var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE - switch (event.momentumPhase) { - case .began: - momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN - case .stationary: - momentum = GHOSTTY_MOUSE_MOMENTUM_STATIONARY - case .changed: - momentum = GHOSTTY_MOUSE_MOMENTUM_CHANGED - case .ended: - momentum = GHOSTTY_MOUSE_MOMENTUM_ENDED - case .cancelled: - momentum = GHOSTTY_MOUSE_MOMENTUM_CANCELLED - case .mayBegin: - momentum = GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN - default: - break - } - - // Pack our momentum value into the mods bitmask - mods |= Int32(momentum.rawValue) << 1 - - ghostty_surface_mouse_scroll(surface, x, y, mods) - } - - override func cursorUpdate(with event: NSEvent) { - switch (cursorVisible) { - case .visible, .hidden: - // Do nothing, stable state - break - - case .pendingHidden: - NSCursor.hide() - cursorVisible = .hidden - - case .pendingVisible: - NSCursor.unhide() - cursorVisible = .visible - } - - cursor.set() - } - - override func keyDown(with event: NSEvent) { - guard let surface = self.surface else { - self.interpretKeyEvents([event]) - return - } - - // We need to translate the mods (maybe) to handle configs such as option-as-alt - let translationModsGhostty = Ghostty.eventModifierFlags( - mods: ghostty_surface_key_translation_mods( - surface, - Ghostty.ghosttyMods(event.modifierFlags) - ) - ) - - // There are hidden bits set in our event that matter for certain dead keys - // so we can't use translationModsGhostty directly. Instead, we just check - // for exact states and set them. - var translationMods = event.modifierFlags - for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { - if (translationModsGhostty.contains(flag)) { - translationMods.insert(flag) - } else { - translationMods.remove(flag) - } - } - - // If the translation modifiers are not equal to our original modifiers - // then we need to construct a new NSEvent. If they are equal we reuse the - // old one. IMPORTANT: we MUST reuse the old event if they're equal because - // this keeps things like Korean input working. There must be some object - // equality happening in AppKit somewhere because this is required. - let translationEvent: NSEvent - if (translationMods == event.modifierFlags) { - translationEvent = event - } else { - translationEvent = NSEvent.keyEvent( - with: event.type, - location: event.locationInWindow, - modifierFlags: translationMods, - timestamp: event.timestamp, - windowNumber: event.windowNumber, - context: nil, - characters: event.characters(byApplyingModifiers: translationMods) ?? "", - charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", - isARepeat: event.isARepeat, - keyCode: event.keyCode - ) ?? event - } - - let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS - - // By setting this to non-nil, we note that we're in a keyDown event. From here, - // we call interpretKeyEvents so that we can handle complex input such as Korean - // language. - keyTextAccumulator = [] - defer { keyTextAccumulator = nil } - - // We need to know what the length of marked text was before this event to - // know if these events cleared it. - let markedTextBefore = markedText.length > 0 - - self.interpretKeyEvents([translationEvent]) - - // If we have text, then we've composed a character, send that down. We do this - // first because if we completed a preedit, the text will be available here - // AND we'll have a preedit. - var handled: Bool = false - if let list = keyTextAccumulator, list.count > 0 { - handled = true - for text in list { - keyAction(action, event: event, text: text) - } - } - - // If we have marked text, we're in a preedit state. Send that down. - // If we don't have marked text but we had marked text before, then the preedit - // was cleared so we want to send down an empty string to ensure we've cleared - // the preedit. - if (markedText.length > 0 || markedTextBefore) { - handled = true - keyAction(action, event: event, preedit: markedText.string) - } - - if (!handled) { - // No text or anything, we want to handle this manually. - keyAction(action, event: event) - } - } - - override func keyUp(with event: NSEvent) { - keyAction(GHOSTTY_ACTION_RELEASE, event: event) - } - - /// Special case handling for some control keys - override func performKeyEquivalent(with event: NSEvent) -> Bool { - // Only process keys when Control is the only modifier - if (!event.modifierFlags.contains(.control) || - !event.modifierFlags.isDisjoint(with: [.shift, .command, .option])) { - return false - } - - // Only process key down events - if (event.type != .keyDown) { - return false - } - - let equivalent: String - switch (event.charactersIgnoringModifiers) { - case "/": - // Treat C-/ as C-_. We do this because C-/ makes macOS make a beep - // sound and we don't like the beep sound. - equivalent = "_" - - default: - // Ignore other events - return false - } - - let newEvent = NSEvent.keyEvent( - with: .keyDown, - location: event.locationInWindow, - modifierFlags: .control, - timestamp: event.timestamp, - windowNumber: event.windowNumber, - context: nil, - characters: equivalent, - charactersIgnoringModifiers: equivalent, - isARepeat: event.isARepeat, - keyCode: event.keyCode - ) - - self.keyDown(with: newEvent!) - return true - } - - override func flagsChanged(with event: NSEvent) { - let mod: UInt32; - switch (event.keyCode) { - case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue - case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue - case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue - case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue - case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue - default: return - } - - // The keyAction function will do this AGAIN below which sucks to repeat - // but this is super cheap and flagsChanged isn't that common. - let mods = Ghostty.ghosttyMods(event.modifierFlags) - - // If the key that pressed this is active, its a press, else release. - var action = GHOSTTY_ACTION_RELEASE - if (mods.rawValue & mod != 0) { - // If the key is pressed, its slightly more complicated, because we - // want to check if the pressed modifier is the correct side. If the - // correct side is pressed then its a press event otherwise its a release - // event with the opposite modifier still held. - let sidePressed: Bool - switch (event.keyCode) { - case 0x3C: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERSHIFTKEYMASK) != 0; - case 0x3E: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCTLKEYMASK) != 0; - case 0x3D: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERALTKEYMASK) != 0; - case 0x36: - sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCMDKEYMASK) != 0; - default: - sidePressed = true - } - - if (sidePressed) { - action = GHOSTTY_ACTION_PRESS - } - } - - keyAction(action, event: event) - } - - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { - guard let surface = self.surface else { return } - - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = nil - key_ev.composing = false - ghostty_surface_key(surface, key_ev) - } - - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) { - guard let surface = self.surface else { return } - - preedit.withCString { ptr in - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = ptr - key_ev.composing = true - ghostty_surface_key(surface, key_ev) - } - } - - private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) { - guard let surface = self.surface else { return } - - text.withCString { ptr in - var key_ev = ghostty_input_key_s() - key_ev.action = action - key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) - key_ev.keycode = UInt32(event.keyCode) - key_ev.text = ptr - ghostty_surface_key(surface, key_ev) - } - } - - // MARK: Menu Handlers - - @IBAction func copy(_ sender: Any?) { - guard let surface = self.surface else { return } - let action = "copy_to_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { - AppDelegate.logger.warning("action failed action=\(action)") - } - } - - @IBAction func paste(_ sender: Any?) { - guard let surface = self.surface else { return } - let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { - AppDelegate.logger.warning("action failed action=\(action)") - } - } - - - @IBAction func pasteAsPlainText(_ sender: Any?) { - guard let surface = self.surface else { return } - let action = "paste_from_clipboard" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { - AppDelegate.logger.warning("action failed action=\(action)") - } - } - - @IBAction override func selectAll(_ sender: Any?) { - guard let surface = self.surface else { return } - let action = "select_all" - if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { - AppDelegate.logger.warning("action failed action=\(action)") - } - } - - // MARK: NSTextInputClient - - func hasMarkedText() -> Bool { - return markedText.length > 0 - } - - func markedRange() -> NSRange { - guard markedText.length > 0 else { return NSRange() } - return NSRange(0...(markedText.length-1)) - } - - func selectedRange() -> NSRange { - return NSRange() - } - - func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { - switch string { - case let v as NSAttributedString: - self.markedText = NSMutableAttributedString(attributedString: v) - - case let v as String: - self.markedText = NSMutableAttributedString(string: v) - - default: - print("unknown marked text: \(string)") - } - } - - func unmarkText() { - self.markedText.mutableString.setString("") - } - - func validAttributesForMarkedText() -> [NSAttributedString.Key] { - return [] - } - - func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { - return nil - } - - func characterIndex(for point: NSPoint) -> Int { - return 0 - } - - func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { - guard let surface = self.surface else { - return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0) - } - - // Ghostty will tell us where it thinks an IME keyboard should render. - var x: Double = 0; - var y: Double = 0; - ghostty_surface_ime_point(surface, &x, &y) - - // Ghostty coordinates are in top-left (0, 0) so we have to convert to - // bottom-left since that is what UIKit expects - let rect = NSMakeRect(x, frame.size.height - y, 0, 0) - - // Convert from view to screen coordinates - guard let window = self.window else { return rect } - return window.convertToScreen(rect) - } - - func insertText(_ string: Any, replacementRange: NSRange) { - // We must have an associated event - guard NSApp.currentEvent != nil else { return } - guard let surface = self.surface else { return } - - // We want the string view of the any value - var chars = "" - switch (string) { - case let v as NSAttributedString: - chars = v.string - case let v as String: - chars = v - default: - return - } - - // If insertText is called, our preedit must be over. - unmarkText() - - // If we have an accumulator we're in another key event so we just - // accumulate and return. - if var acc = keyTextAccumulator { - acc.append(chars) - keyTextAccumulator = acc - return - } - - let len = chars.utf8CString.count - if (len == 0) { return } - - chars.withCString { ptr in - // len includes the null terminator so we do len - 1 - ghostty_surface_text(surface, ptr, UInt(len - 1)) - } - } - - override func doCommand(by selector: Selector) { - // This currently just prevents NSBeep from interpretKeyEvents but in the future - // we may want to make some of this work. - - print("SEL: \(selector)") - } - - /// Show a user notification and associate it with this surface - func showUserNotification(title: String, body: String) { - let content = UNMutableNotificationContent() - content.title = title - content.subtitle = self.title - content.body = body - content.sound = UNNotificationSound.default - content.categoryIdentifier = Ghostty.userNotificationCategory - content.userInfo = ["surface": self.uuid.uuidString] - - let uuid = UUID().uuidString - let request = UNNotificationRequest( - identifier: uuid, - content: content, - trigger: nil - ) - - UNUserNotificationCenter.current().add(request) { error in - if let error = error { - AppDelegate.logger.error("Error scheduling user notification: \(error)") - return - } - - self.notificationIdentifiers.insert(uuid) - } - } - - /// Handle a user notification click - func handleUserNotification(notification: UNNotification, focus: Bool) { - let id = notification.request.identifier - guard self.notificationIdentifiers.remove(id) != nil else { return } - if focus { - self.window?.makeKeyAndOrderFront(self) - Ghostty.moveFocus(to: self) - } - } - } } // MARK: Surface Environment Keys @@ -1302,12 +372,12 @@ extension FocusedValues { } extension FocusedValues { - var ghosttySurfaceCellSize: NSSize? { + var ghosttySurfaceCellSize: OSSize? { get { self[FocusedGhosttySurfaceCellSize.self] } set { self[FocusedGhosttySurfaceCellSize.self] = newValue } } struct FocusedGhosttySurfaceCellSize: FocusedValueKey { - typealias Value = NSSize + typealias Value = OSSize } } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift new file mode 100644 index 000000000..fb1915fc1 --- /dev/null +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -0,0 +1,955 @@ +import SwiftUI +import UserNotifications +import GhosttyKit + +extension Ghostty { + /// The NSView implementation for a terminal surface. + class SurfaceView: OSView, ObservableObject { + /// Unique ID per surface + let uuid: UUID + + // The current title of the surface as defined by the pty. This can be + // changed with escape codes. This is public because the callbacks go + // to the app level and it is set from there. + @Published var title: String = "👻" + + // The cell size of this surface. This is set by the core when the + // surface is first created and any time the cell size changes (i.e. + // when the font size changes). This is used to allow windows to be + // resized in discrete steps of a single cell. + @Published var cellSize: NSSize = .zero + + // The health state of the surface. This currently only reflects the + // renderer health. In the future we may want to make this an enum. + @Published var healthy: Bool = true + + // Any error while initializing the surface. + @Published var error: Error? = nil + + // An initial size to request for a window. This will only affect + // then the view is moved to a new window. + var initialSize: NSSize? = nil + + // Returns true if quit confirmation is required for this surface to + // exit safely. + var needsConfirmQuit: Bool { + guard let surface = self.surface else { return false } + return ghostty_surface_needs_confirm_quit(surface) + } + + /// Returns the pwd of the surface if it has one. + var pwd: String? { + guard let surface = self.surface else { return nil } + let v = String(unsafeUninitializedCapacity: 1024) { + Int(ghostty_surface_pwd(surface, $0.baseAddress, UInt($0.count))) + } + + if (v.count == 0) { return nil } + return v + } + + // Returns the inspector instance for this surface, or nil if the + // surface has been closed. + var inspector: ghostty_inspector_t? { + guard let surface = self.surface else { return nil } + return ghostty_surface_inspector(surface) + } + + // True if the inspector should be visible + @Published var inspectorVisible: Bool = false { + didSet { + if (oldValue && !inspectorVisible) { + guard let surface = self.surface else { return } + ghostty_inspector_free(surface) + } + } + } + + // Notification identifiers associated with this surface + var notificationIdentifiers: Set = [] + + private(set) var surface: ghostty_surface_t? + private var markedText: NSMutableAttributedString + private var mouseEntered: Bool = false + private(set) var focused: Bool = true + private var cursor: NSCursor = .iBeam + private var cursorVisible: CursorVisibility = .visible + + // This is set to non-null during keyDown to accumulate insertText contents + private var keyTextAccumulator: [String]? = nil + + // We need to support being a first responder so that we can get input events + override var acceptsFirstResponder: Bool { return true } + + // I don't think we need this but this lets us know we should redraw our layer + // so we'll use that to tell ghostty to refresh. + override var wantsUpdateLayer: Bool { return true } + + // State machine for mouse cursor visibility because every call to + // NSCursor.hide/unhide must be balanced. + enum CursorVisibility { + case visible + case hidden + case pendingVisible + case pendingHidden + } + + init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { + self.markedText = NSMutableAttributedString() + self.uuid = uuid ?? .init() + + // Initialize with some default frame size. The important thing is that this + // is non-zero so that our layer bounds are non-zero so that our renderer + // can do SOMETHING. + super.init(frame: NSMakeRect(0, 0, 800, 600)) + + // Before we initialize the surface we want to register our notifications + // so there is no window where we can't receive them. + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(onUpdateRendererHealth), + name: Ghostty.Notification.didUpdateRendererHealth, + object: self) + + // Setup our surface. This will also initialize all the terminal IO. + let surface_cfg = baseConfig ?? SurfaceConfiguration() + var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) + guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { + self.error = AppError.surfaceCreateError + return + } + self.surface = surface; + + // Setup our tracking area so we get mouse moved events + updateTrackingAreas() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported for this view") + } + + deinit { + // Remove all of our notificationcenter subscriptions + let center = NotificationCenter.default + center.removeObserver(self) + + // Whenever the surface is removed, we need to note that our restorable + // state is invalid to prevent the surface from being restored. + invalidateRestorableState() + + trackingAreas.forEach { removeTrackingArea($0) } + + // mouseExited is not called by AppKit one last time when the view + // closes so we do it manually to ensure our NSCursor state remains + // accurate. + if (mouseEntered) { + mouseExited(with: NSEvent()) + } + + guard let surface = self.surface else { return } + ghostty_surface_free(surface) + } + + /// Close the surface early. This will free the associated Ghostty surface and the view will + /// no longer render. The view can never be used again. This is a way for us to free the + /// Ghostty resources while references may still be held to this view. I've found that SwiftUI + /// tends to hold this view longer than it should so we free the expensive stuff explicitly. + func close() { + // Remove any notifications associated with this surface + let identifiers = Array(self.notificationIdentifiers) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) + + guard let surface = self.surface else { return } + ghostty_surface_free(surface) + self.surface = nil + } + + func focusDidChange(_ focused: Bool) { + guard let surface = self.surface else { return } + ghostty_surface_set_focus(surface, focused) + } + + func sizeDidChange(_ size: CGSize) { + guard let surface = self.surface else { return } + + // Ghostty wants to know the actual framebuffer size... It is very important + // here that we use "size" and NOT the view frame. If we're in the middle of + // an animation (i.e. a fullscreen animation), the frame will not yet be updated. + // The size represents our final size we're going for. + let scaledSize = self.convertToBacking(size) + ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height)) + + // Frame changes do not always call mouseEntered/mouseExited, so we do some + // calculations ourself to call those events. + if let window = self.window { + let mouseScreen = NSEvent.mouseLocation + let mouseWindow = window.convertPoint(fromScreen: mouseScreen) + let mouseView = self.convert(mouseWindow, from: nil) + let isEntered = self.isMousePoint(mouseView, in: bounds) + if (isEntered) { + mouseEntered(with: NSEvent()) + } else { + mouseExited(with: NSEvent()) + } + } else { + // If we don't have a window, then our mouse can NOT be in our view. + // When the window comes back, I believe this event fires again so + // we'll get a mouseEntered. + mouseExited(with: NSEvent()) + } + } + + func setCursorShape(_ shape: ghostty_mouse_shape_e) { + switch (shape) { + case GHOSTTY_MOUSE_SHAPE_DEFAULT: + cursor = .arrow + + case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: + cursor = .contextualMenu + + case GHOSTTY_MOUSE_SHAPE_TEXT: + cursor = .iBeam + + case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: + cursor = .crosshair + + case GHOSTTY_MOUSE_SHAPE_GRAB: + cursor = .openHand + + case GHOSTTY_MOUSE_SHAPE_GRABBING: + cursor = .closedHand + + case GHOSTTY_MOUSE_SHAPE_POINTER: + cursor = .pointingHand + + case GHOSTTY_MOUSE_SHAPE_W_RESIZE: + cursor = .resizeLeft + + case GHOSTTY_MOUSE_SHAPE_E_RESIZE: + cursor = .resizeRight + + case GHOSTTY_MOUSE_SHAPE_N_RESIZE: + cursor = .resizeUp + + case GHOSTTY_MOUSE_SHAPE_S_RESIZE: + cursor = .resizeDown + + case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: + cursor = .resizeUpDown + + case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: + cursor = .resizeLeftRight + + case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: + cursor = .iBeamCursorForVerticalLayout + + case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: + cursor = .operationNotAllowed + + default: + // We ignore unknown shapes. + return + } + + // Set our cursor immediately if our mouse is over our window + if (mouseEntered) { cursorUpdate(with: NSEvent()) } + if let window = self.window { + window.invalidateCursorRects(for: self) + } + } + + func setCursorVisibility(_ visible: Bool) { + switch (cursorVisible) { + case .visible: + // If we want to be visible, do nothing. If we want to be hidden + // enter the pending state. + if (visible) { return } + cursorVisible = .pendingHidden + + case .hidden: + // If we want to be hidden, do nothing. If we want to be visible + // enter the pending state. + if (!visible) { return } + cursorVisible = .pendingVisible + + case .pendingVisible: + // If we want to be visible, do nothing because we're already pending. + // If we want to be hidden, we're already hidden so reset state. + if (visible) { return } + cursorVisible = .hidden + + case .pendingHidden: + // If we want to be hidden, do nothing because we're pending that switch. + // If we want to be visible, we're already visible so reset state. + if (!visible) { return } + cursorVisible = .visible + } + + if (mouseEntered) { + cursorUpdate(with: NSEvent()) + } + } + + // MARK: - Notifications + + @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { + guard let healthAny = notification.userInfo?["health"] else { return } + guard let health = healthAny as? ghostty_renderer_health_e else { return } + healthy = health == GHOSTTY_RENDERER_HEALTH_OK + } + + // MARK: - NSView + + override func viewDidMoveToWindow() { + // Set our background blur if requested + setWindowBackgroundBlur(window) + } + + /// This function sets the window background to blur if it is configured on the surface. + private func setWindowBackgroundBlur(_ targetWindow: NSWindow?) { + // Surface must desire transparency + guard let surface = self.surface, + ghostty_surface_transparent(surface) else { return } + + // Our target should always be our own view window + guard let target = targetWindow, + let window = self.window, + target == window else { return } + + // If our window is not visible, then delay this. This is possible specifically + // during state restoration but probably in other scenarios as well. To delay, + // we just loop directly on the dispatch queue. + guard window.isVisible else { + // Weak window so that if the window changes or is destroyed we aren't holding a ref + DispatchQueue.main.async { [weak self, weak window] in self?.setWindowBackgroundBlur(window) } + return + } + + // Set the window transparency settings + window.isOpaque = false + window.hasShadow = false + window.backgroundColor = .clear + + // If we have a blur, set the blur + ghostty_set_window_background_blur(surface, Unmanaged.passUnretained(window).toOpaque()) + } + + override func becomeFirstResponder() -> Bool { + let result = super.becomeFirstResponder() + if (result) { focused = true } + return result + } + + override func resignFirstResponder() -> Bool { + let result = super.resignFirstResponder() + + // We sometimes call this manually (see SplitView) as a way to force us to + // yield our focus state. + if (result) { + focusDidChange(false) + focused = false + } + + return result + } + + override func updateTrackingAreas() { + // To update our tracking area we just recreate it all. + trackingAreas.forEach { removeTrackingArea($0) } + + // This tracking area is across the entire frame to notify us of mouse movements. + addTrackingArea(NSTrackingArea( + rect: frame, + options: [ + .mouseEnteredAndExited, + .mouseMoved, + + // Only send mouse events that happen in our visible (not obscured) rect + .inVisibleRect, + + // We want active always because we want to still send mouse reports + // even if we're not focused or key. + .activeAlways, + ], + owner: self, + userInfo: nil)) + } + + override func resetCursorRects() { + discardCursorRects() + addCursorRect(frame, cursor: self.cursor) + } + + override func viewDidChangeBackingProperties() { + guard let surface = self.surface else { return } + + // Detect our X/Y scale factor so we can update our surface + let fbFrame = self.convertToBacking(self.frame) + let xScale = fbFrame.size.width / self.frame.size.width + let yScale = fbFrame.size.height / self.frame.size.height + ghostty_surface_set_content_scale(surface, xScale, yScale) + + // When our scale factor changes, so does our fb size so we send that too + ghostty_surface_set_size(surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height)) + } + + override func updateLayer() { + guard let surface = self.surface else { return } + ghostty_surface_refresh(surface); + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + // "Override this method in a subclass to allow instances to respond to + // click-through. This allows the user to click on a view in an inactive + // window, activating the view with one click, instead of clicking first + // to make the window active and then clicking the view." + return true + } + + override func mouseDown(with event: NSEvent) { + guard let surface = self.surface else { return } + let mods = Ghostty.ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods) + } + + override func mouseUp(with event: NSEvent) { + guard let surface = self.surface else { return } + let mods = Ghostty.ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods) + } + + override func otherMouseDown(with event: NSEvent) { + guard let surface = self.surface else { return } + guard event.buttonNumber == 2 else { return } + let mods = Ghostty.ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_MIDDLE, mods) + } + + override func otherMouseUp(with event: NSEvent) { + guard let surface = self.surface else { return } + guard event.buttonNumber == 2 else { return } + let mods = Ghostty.ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_MIDDLE, mods) + } + + + override func rightMouseDown(with event: NSEvent) { + guard let surface = self.surface else { return } + let mods = Ghostty.ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods) + } + + override func rightMouseUp(with event: NSEvent) { + guard let surface = self.surface else { return } + let mods = Ghostty.ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods) + } + + override func mouseMoved(with event: NSEvent) { + guard let surface = self.surface else { return } + + // Convert window position to view position. Note (0, 0) is bottom left. + let pos = self.convert(event.locationInWindow, from: nil) + ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y) + + } + + override func mouseDragged(with event: NSEvent) { + self.mouseMoved(with: event) + } + + override func mouseEntered(with event: NSEvent) { + // For reasons unknown (Cocoaaaaaaaaa), mouseEntered is called + // multiple times in an unbalanced way with mouseExited when a new + // tab is created. In this scenario, we only want to process our + // callback once since this is stateful and we expect balancing. + if (mouseEntered) { return } + + mouseEntered = true + + // Update our cursor when we enter so we fully process our + // cursorVisible state. + cursorUpdate(with: NSEvent()) + } + + override func mouseExited(with event: NSEvent) { + // See mouseEntered + if (!mouseEntered) { return } + + mouseEntered = false + + // If the mouse is currently hidden, we want to show it when we exit + // this view. We go through the cursorVisible dance so that only + // cursorUpdate manages cursor state. + if (cursorVisible == .hidden) { + cursorVisible = .pendingVisible + cursorUpdate(with: NSEvent()) + assert(cursorVisible == .visible) + + // We set the state to pending hidden again for the next time + // we enter. + cursorVisible = .pendingHidden + } + } + + override func scrollWheel(with event: NSEvent) { + guard let surface = self.surface else { return } + + // Builds up the "input.ScrollMods" bitmask + var mods: Int32 = 0 + + var x = event.scrollingDeltaX + var y = event.scrollingDeltaY + if event.hasPreciseScrollingDeltas { + mods = 1 + + // We do a 2x speed multiplier. This is subjective, it "feels" better to me. + x *= 2; + y *= 2; + + // TODO(mitchellh): do we have to scale the x/y here by window scale factor? + } + + // Determine our momentum value + var momentum: ghostty_input_mouse_momentum_e = GHOSTTY_MOUSE_MOMENTUM_NONE + switch (event.momentumPhase) { + case .began: + momentum = GHOSTTY_MOUSE_MOMENTUM_BEGAN + case .stationary: + momentum = GHOSTTY_MOUSE_MOMENTUM_STATIONARY + case .changed: + momentum = GHOSTTY_MOUSE_MOMENTUM_CHANGED + case .ended: + momentum = GHOSTTY_MOUSE_MOMENTUM_ENDED + case .cancelled: + momentum = GHOSTTY_MOUSE_MOMENTUM_CANCELLED + case .mayBegin: + momentum = GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN + default: + break + } + + // Pack our momentum value into the mods bitmask + mods |= Int32(momentum.rawValue) << 1 + + ghostty_surface_mouse_scroll(surface, x, y, mods) + } + + override func cursorUpdate(with event: NSEvent) { + switch (cursorVisible) { + case .visible, .hidden: + // Do nothing, stable state + break + + case .pendingHidden: + NSCursor.hide() + cursorVisible = .hidden + + case .pendingVisible: + NSCursor.unhide() + cursorVisible = .visible + } + + cursor.set() + } + + override func keyDown(with event: NSEvent) { + guard let surface = self.surface else { + self.interpretKeyEvents([event]) + return + } + + // We need to translate the mods (maybe) to handle configs such as option-as-alt + let translationModsGhostty = Ghostty.eventModifierFlags( + mods: ghostty_surface_key_translation_mods( + surface, + Ghostty.ghosttyMods(event.modifierFlags) + ) + ) + + // There are hidden bits set in our event that matter for certain dead keys + // so we can't use translationModsGhostty directly. Instead, we just check + // for exact states and set them. + var translationMods = event.modifierFlags + for flag in [NSEvent.ModifierFlags.shift, .control, .option, .command] { + if (translationModsGhostty.contains(flag)) { + translationMods.insert(flag) + } else { + translationMods.remove(flag) + } + } + + // If the translation modifiers are not equal to our original modifiers + // then we need to construct a new NSEvent. If they are equal we reuse the + // old one. IMPORTANT: we MUST reuse the old event if they're equal because + // this keeps things like Korean input working. There must be some object + // equality happening in AppKit somewhere because this is required. + let translationEvent: NSEvent + if (translationMods == event.modifierFlags) { + translationEvent = event + } else { + translationEvent = NSEvent.keyEvent( + with: event.type, + location: event.locationInWindow, + modifierFlags: translationMods, + timestamp: event.timestamp, + windowNumber: event.windowNumber, + context: nil, + characters: event.characters(byApplyingModifiers: translationMods) ?? "", + charactersIgnoringModifiers: event.charactersIgnoringModifiers ?? "", + isARepeat: event.isARepeat, + keyCode: event.keyCode + ) ?? event + } + + let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS + + // By setting this to non-nil, we note that we're in a keyDown event. From here, + // we call interpretKeyEvents so that we can handle complex input such as Korean + // language. + keyTextAccumulator = [] + defer { keyTextAccumulator = nil } + + // We need to know what the length of marked text was before this event to + // know if these events cleared it. + let markedTextBefore = markedText.length > 0 + + self.interpretKeyEvents([translationEvent]) + + // If we have text, then we've composed a character, send that down. We do this + // first because if we completed a preedit, the text will be available here + // AND we'll have a preedit. + var handled: Bool = false + if let list = keyTextAccumulator, list.count > 0 { + handled = true + for text in list { + keyAction(action, event: event, text: text) + } + } + + // If we have marked text, we're in a preedit state. Send that down. + // If we don't have marked text but we had marked text before, then the preedit + // was cleared so we want to send down an empty string to ensure we've cleared + // the preedit. + if (markedText.length > 0 || markedTextBefore) { + handled = true + keyAction(action, event: event, preedit: markedText.string) + } + + if (!handled) { + // No text or anything, we want to handle this manually. + keyAction(action, event: event) + } + } + + override func keyUp(with event: NSEvent) { + keyAction(GHOSTTY_ACTION_RELEASE, event: event) + } + + /// Special case handling for some control keys + override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Only process keys when Control is the only modifier + if (!event.modifierFlags.contains(.control) || + !event.modifierFlags.isDisjoint(with: [.shift, .command, .option])) { + return false + } + + // Only process key down events + if (event.type != .keyDown) { + return false + } + + let equivalent: String + switch (event.charactersIgnoringModifiers) { + case "/": + // Treat C-/ as C-_. We do this because C-/ makes macOS make a beep + // sound and we don't like the beep sound. + equivalent = "_" + + default: + // Ignore other events + return false + } + + let newEvent = NSEvent.keyEvent( + with: .keyDown, + location: event.locationInWindow, + modifierFlags: .control, + timestamp: event.timestamp, + windowNumber: event.windowNumber, + context: nil, + characters: equivalent, + charactersIgnoringModifiers: equivalent, + isARepeat: event.isARepeat, + keyCode: event.keyCode + ) + + self.keyDown(with: newEvent!) + return true + } + + override func flagsChanged(with event: NSEvent) { + let mod: UInt32; + switch (event.keyCode) { + case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue + case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue + case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue + case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue + case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue + default: return + } + + // The keyAction function will do this AGAIN below which sucks to repeat + // but this is super cheap and flagsChanged isn't that common. + let mods = Ghostty.ghosttyMods(event.modifierFlags) + + // If the key that pressed this is active, its a press, else release. + var action = GHOSTTY_ACTION_RELEASE + if (mods.rawValue & mod != 0) { + // If the key is pressed, its slightly more complicated, because we + // want to check if the pressed modifier is the correct side. If the + // correct side is pressed then its a press event otherwise its a release + // event with the opposite modifier still held. + let sidePressed: Bool + switch (event.keyCode) { + case 0x3C: + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERSHIFTKEYMASK) != 0; + case 0x3E: + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCTLKEYMASK) != 0; + case 0x3D: + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERALTKEYMASK) != 0; + case 0x36: + sidePressed = event.modifierFlags.rawValue & UInt(NX_DEVICERCMDKEYMASK) != 0; + default: + sidePressed = true + } + + if (sidePressed) { + action = GHOSTTY_ACTION_PRESS + } + } + + keyAction(action, event: event) + } + + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent) { + guard let surface = self.surface else { return } + + var key_ev = ghostty_input_key_s() + key_ev.action = action + key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) + key_ev.keycode = UInt32(event.keyCode) + key_ev.text = nil + key_ev.composing = false + ghostty_surface_key(surface, key_ev) + } + + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, preedit: String) { + guard let surface = self.surface else { return } + + preedit.withCString { ptr in + var key_ev = ghostty_input_key_s() + key_ev.action = action + key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) + key_ev.keycode = UInt32(event.keyCode) + key_ev.text = ptr + key_ev.composing = true + ghostty_surface_key(surface, key_ev) + } + } + + private func keyAction(_ action: ghostty_input_action_e, event: NSEvent, text: String) { + guard let surface = self.surface else { return } + + text.withCString { ptr in + var key_ev = ghostty_input_key_s() + key_ev.action = action + key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) + key_ev.keycode = UInt32(event.keyCode) + key_ev.text = ptr + ghostty_surface_key(surface, key_ev) + } + } + + // MARK: Menu Handlers + + @IBAction func copy(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "copy_to_clipboard" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction func paste(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "paste_from_clipboard" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + + @IBAction func pasteAsPlainText(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "paste_from_clipboard" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + @IBAction override func selectAll(_ sender: Any?) { + guard let surface = self.surface else { return } + let action = "select_all" + if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) { + AppDelegate.logger.warning("action failed action=\(action)") + } + } + + /// Show a user notification and associate it with this surface + func showUserNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.subtitle = self.title + content.body = body + content.sound = UNNotificationSound.default + content.categoryIdentifier = Ghostty.userNotificationCategory + content.userInfo = ["surface": self.uuid.uuidString] + + let uuid = UUID().uuidString + let request = UNNotificationRequest( + identifier: uuid, + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + AppDelegate.logger.error("Error scheduling user notification: \(error)") + return + } + + self.notificationIdentifiers.insert(uuid) + } + } + + /// Handle a user notification click + func handleUserNotification(notification: UNNotification, focus: Bool) { + let id = notification.request.identifier + guard self.notificationIdentifiers.remove(id) != nil else { return } + if focus { + self.window?.makeKeyAndOrderFront(self) + Ghostty.moveFocus(to: self) + } + } + } +} + +// MARK: - NSTextInputClient + +extension Ghostty.SurfaceView: NSTextInputClient { + func hasMarkedText() -> Bool { + return markedText.length > 0 + } + + func markedRange() -> NSRange { + guard markedText.length > 0 else { return NSRange() } + return NSRange(0...(markedText.length-1)) + } + + func selectedRange() -> NSRange { + return NSRange() + } + + func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) { + switch string { + case let v as NSAttributedString: + self.markedText = NSMutableAttributedString(attributedString: v) + + case let v as String: + self.markedText = NSMutableAttributedString(string: v) + + default: + print("unknown marked text: \(string)") + } + } + + func unmarkText() { + self.markedText.mutableString.setString("") + } + + func validAttributesForMarkedText() -> [NSAttributedString.Key] { + return [] + } + + func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? { + return nil + } + + func characterIndex(for point: NSPoint) -> Int { + return 0 + } + + func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect { + guard let surface = self.surface else { + return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0) + } + + // Ghostty will tell us where it thinks an IME keyboard should render. + var x: Double = 0; + var y: Double = 0; + ghostty_surface_ime_point(surface, &x, &y) + + // Ghostty coordinates are in top-left (0, 0) so we have to convert to + // bottom-left since that is what UIKit expects + let rect = NSMakeRect(x, frame.size.height - y, 0, 0) + + // Convert from view to screen coordinates + guard let window = self.window else { return rect } + return window.convertToScreen(rect) + } + + func insertText(_ string: Any, replacementRange: NSRange) { + // We must have an associated event + guard NSApp.currentEvent != nil else { return } + guard let surface = self.surface else { return } + + // We want the string view of the any value + var chars = "" + switch (string) { + case let v as NSAttributedString: + chars = v.string + case let v as String: + chars = v + default: + return + } + + // If insertText is called, our preedit must be over. + unmarkText() + + // If we have an accumulator we're in another key event so we just + // accumulate and return. + if var acc = keyTextAccumulator { + acc.append(chars) + keyTextAccumulator = acc + return + } + + let len = chars.utf8CString.count + if (len == 0) { return } + + chars.withCString { ptr in + // len includes the null terminator so we do len - 1 + ghostty_surface_text(surface, ptr, UInt(len - 1)) + } + } + + override func doCommand(by selector: Selector) { + // This currently just prevents NSBeep from interpretKeyEvents but in the future + // we may want to make some of this work. + + print("SEL: \(selector)") + } +} diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift new file mode 100644 index 000000000..83a968458 --- /dev/null +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -0,0 +1,72 @@ +import SwiftUI +import GhosttyKit + +extension Ghostty { + /// The UIView implementation for a terminal surface. + class SurfaceView: UIView, ObservableObject { + /// Unique ID per surface + let uuid: UUID + + // The current title of the surface as defined by the pty. This can be + // changed with escape codes. This is public because the callbacks go + // to the app level and it is set from there. + @Published var title: String = "👻" + + // The cell size of this surface. This is set by the core when the + // surface is first created and any time the cell size changes (i.e. + // when the font size changes). This is used to allow windows to be + // resized in discrete steps of a single cell. + @Published var cellSize: OSSize = .zero + + // The health state of the surface. This currently only reflects the + // renderer health. In the future we may want to make this an enum. + @Published var healthy: Bool = true + + // Any error while initializing the surface. + @Published var error: Error? = nil + + private(set) var surface: ghostty_surface_t? + + init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { + self.uuid = uuid ?? .init() + + // Initialize with some default frame size. The important thing is that this + // is non-zero so that our layer bounds are non-zero so that our renderer + // can do SOMETHING. + super.init(frame: CGRect(x: 0, y: 0, width: 800, height: 600)) + + // Setup our surface. This will also initialize all the terminal IO. + let surface_cfg = baseConfig ?? SurfaceConfiguration() + var surface_cfg_c = surface_cfg.ghosttyConfig(view: self) + guard let surface = ghostty_surface_new(app, &surface_cfg_c) else { + // TODO + return + } + self.surface = surface; + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported for this view") + } + + deinit { + guard let surface = self.surface else { return } + ghostty_surface_free(surface) + } + + override class var layerClass: AnyClass { + get { + return CAMetalLayer.self + } + } + + func focusDidChange(_ focused: Bool) { + guard let surface = self.surface else { return } + ghostty_surface_set_focus(surface, focused) + } + + func sizeDidChange(_ size: CGSize) { + // TODO + } + } +} diff --git a/macos/Sources/Helpers/CrossKit.swift b/macos/Sources/Helpers/CrossKit.swift new file mode 100644 index 000000000..5a69b45a3 --- /dev/null +++ b/macos/Sources/Helpers/CrossKit.swift @@ -0,0 +1,54 @@ +// This file is a helper to bridge some types that are effectively identical +// between AppKit and UIKit. + +import SwiftUI + +#if canImport(AppKit) + +import AppKit + +typealias OSView = NSView +typealias OSColor = NSColor +typealias OSSize = NSSize + +protocol OSViewRepresentable: NSViewRepresentable where NSViewType == OSViewType { + associatedtype OSViewType: NSView + func makeOSView(context: Context) -> OSViewType + func updateOSView(_ osView: OSViewType, context: Context) +} + +extension OSViewRepresentable { + func makeNSView(context: Context) -> OSViewType { + makeOSView(context: context) + } + + func updateNSView(_ nsView: OSViewType, context: Context) { + updateOSView(nsView, context: context) + } +} + +#elseif canImport(UIKit) + +import UIKit + +typealias OSView = UIView +typealias OSColor = UIColor +typealias OSSize = CGSize + +protocol OSViewRepresentable: UIViewRepresentable { + associatedtype OSViewType: UIView + func makeOSView(context: Context) -> OSViewType + func updateOSView(_ osView: OSViewType, context: Context) +} + +extension OSViewRepresentable { + func makeUIView(context: Context) -> OSViewType { + makeOSView(context: context) + } + + func updateUIView(_ uiView: OSViewType, context: Context) { + updateOSView(uiView, context: context) + } +} + +#endif From fd782746d4254a5c331f582e2c9461f9ae4366d7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Jan 2024 20:26:42 -0800 Subject: [PATCH 6/7] macos: set proper content size for UIView views --- macos/Sources/Ghostty/SurfaceView_UIKit.swift | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_UIKit.swift b/macos/Sources/Ghostty/SurfaceView_UIKit.swift index 83a968458..bda16ced8 100644 --- a/macos/Sources/Ghostty/SurfaceView_UIKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_UIKit.swift @@ -54,19 +54,37 @@ extension Ghostty { ghostty_surface_free(surface) } - override class var layerClass: AnyClass { - get { - return CAMetalLayer.self - } - } - func focusDidChange(_ focused: Bool) { guard let surface = self.surface else { return } ghostty_surface_set_focus(surface, focused) } func sizeDidChange(_ size: CGSize) { - // TODO + guard let surface = self.surface else { return } + + // Ghostty wants to know the actual framebuffer size... It is very important + // here that we use "size" and NOT the view frame. If we're in the middle of + // an animation (i.e. a fullscreen animation), the frame will not yet be updated. + // The size represents our final size we're going for. + let scale = self.contentScaleFactor + ghostty_surface_set_content_scale(surface, scale, scale) + ghostty_surface_set_size( + surface, + UInt32(size.width * scale), + UInt32(size.height * scale) + ) + } + + // MARK: UIView + + override class var layerClass: AnyClass { + get { + return CAMetalLayer.self + } + } + + override func didMoveToWindow() { + sizeDidChange(frame.size) } } } From 8b01d795022d4569b5c28036eaff600e0e688057 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 18 Jan 2024 20:31:41 -0800 Subject: [PATCH 7/7] macos: iOS bg color extends to unsafe areas --- macos/Sources/App/iOS/iOSApp.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/macos/Sources/App/iOS/iOSApp.swift b/macos/Sources/App/iOS/iOSApp.swift index 50c115293..ba1993296 100644 --- a/macos/Sources/App/iOS/iOSApp.swift +++ b/macos/Sources/App/iOS/iOSApp.swift @@ -16,7 +16,12 @@ struct iOS_GhosttyTerminal: View { @EnvironmentObject private var ghostty_app: Ghostty.App var body: some View { - Ghostty.Terminal() + ZStack { + // Make sure that our background color extends to all parts of the screen + Color(ghostty_app.config.backgroundColor).ignoresSafeArea() + + Ghostty.Terminal() + } } }