From 6b450f7c7d597dac5debe33452a6e83fac830aaa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 18 Feb 2023 16:51:36 -0800 Subject: [PATCH] macos: track surface focus state --- include/ghostty.h | 1 + macos/Ghostty.xcodeproj/project.pbxproj | 10 +++- macos/Sources/GhosttyApp.swift | 3 +- macos/Sources/TerminalSurfaceView.swift | 11 +++- macos/Sources/TerminalView.swift | 19 ++++++ macos/Sources/WindowTracker.swift | 80 +++++++++++++++++++++++++ src/App.zig | 5 ++ src/apprt/embedded.zig | 7 +++ 8 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 macos/Sources/TerminalView.swift create mode 100644 macos/Sources/WindowTracker.swift diff --git a/include/ghostty.h b/include/ghostty.h index 097e8ea18..4a4d84e7f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -211,6 +211,7 @@ ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*); void ghostty_surface_free(ghostty_surface_t); void ghostty_surface_refresh(ghostty_surface_t); void ghostty_surface_set_content_scale(ghostty_surface_t, double, double); +void ghostty_surface_set_focus(ghostty_surface_t, bool); void ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t); void ghostty_surface_key(ghostty_surface_t, ghostty_input_action_e, ghostty_input_key_e, ghostty_input_mods_e); void ghostty_surface_char(ghostty_surface_t, uint32_t); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a0a9e76dd..71f0d767e 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -8,6 +8,8 @@ /* Begin PBXBuildFile section */ A507573E299FF33C009D7DC7 /* TerminalSurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */; }; + A518502429A197C700E4CC4F /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518502329A197C700E4CC4F /* TerminalView.swift */; }; + A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518502529A1A45100E4CC4F /* WindowTracker.swift */; }; A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyApp.swift */; }; @@ -17,6 +19,8 @@ /* Begin PBXFileReference section */ A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurfaceView.swift; sourceTree = ""; }; + A518502329A197C700E4CC4F /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; + A518502529A1A45100E4CC4F /* WindowTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTracker.swift; sourceTree = ""; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -44,8 +48,10 @@ A5D495A0299BEC2200DD1313 /* Preview Content */, A5B30534299BEAAA0047F10C /* GhosttyApp.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, - A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, + A518502329A197C700E4CC4F /* TerminalView.swift */, + A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */, + A518502529A1A45100E4CC4F /* WindowTracker.swift */, ); path = Sources; sourceTree = ""; @@ -153,6 +159,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */, + A518502429A197C700E4CC4F /* TerminalView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, A507573E299FF33C009D7DC7 /* TerminalSurfaceView.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index a7dc3ed04..b6a7066c9 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -20,7 +20,8 @@ struct GhosttyApp: App { case .error: ErrorView() case .ready: - TerminalSurfaceView(app: ghostty.app!) + TerminalView(app: ghostty.app!) + .modifier(WindowObservationModifier()) } } } diff --git a/macos/Sources/TerminalSurfaceView.swift b/macos/Sources/TerminalSurfaceView.swift index 6f54a80f8..cd4659f6f 100644 --- a/macos/Sources/TerminalSurfaceView.swift +++ b/macos/Sources/TerminalSurfaceView.swift @@ -10,10 +10,12 @@ import GhosttyKit /// 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 TerminalSurfaceView: NSViewRepresentable { + var hasFocus: Bool @StateObject private var state: TerminalSurfaceView_Real - init(app: ghostty_app_t) { + init(app: ghostty_app_t, hasFocus: Bool) { self._state = StateObject(wrappedValue: TerminalSurfaceView_Real(app)) + self.hasFocus = hasFocus } func makeNSView(context: Context) -> TerminalSurfaceView_Real { @@ -24,7 +26,7 @@ struct TerminalSurfaceView: NSViewRepresentable { } func updateNSView(_ view: TerminalSurfaceView_Real, context: Context) { - // Nothing we need to do here. + state.focusDidChange(hasFocus) } } @@ -187,6 +189,11 @@ class TerminalSurfaceView_Real: NSView, NSTextInputClient, ObservableObject { ghostty_surface_free(surface) } + func focusDidChange(_ focused: Bool) { + guard let surface = self.surface else { return } + ghostty_surface_set_focus(surface, focused ? 1 : 0) + } + override func resize(withOldSuperviewSize oldSize: NSSize) { super.resize(withOldSuperviewSize: oldSize) diff --git a/macos/Sources/TerminalView.swift b/macos/Sources/TerminalView.swift new file mode 100644 index 000000000..b04a7ff4f --- /dev/null +++ b/macos/Sources/TerminalView.swift @@ -0,0 +1,19 @@ +import SwiftUI +import GhosttyKit + +struct TerminalView: View { + let app: ghostty_app_t + @FocusState private var surfaceFocus: Bool + @Environment(\.isKeyWindow) private var isKeyWindow: Bool + + // This is true if the terminal is considered "focused". The terminal is focused if + // it is both individually focused and the containing window is key. + private var hasFocus: Bool { surfaceFocus && isKeyWindow } + + var body: some View { + VStack { + TerminalSurfaceView(app: app, hasFocus: hasFocus) + .focused($surfaceFocus) + } + } +} diff --git a/macos/Sources/WindowTracker.swift b/macos/Sources/WindowTracker.swift new file mode 100644 index 000000000..c936db253 --- /dev/null +++ b/macos/Sources/WindowTracker.swift @@ -0,0 +1,80 @@ +import SwiftUI + +/// This modifier tracks whether the window is the key window in the isKeyWindow environment value. +struct WindowObservationModifier: ViewModifier { + @StateObject var windowObserver: WindowObserver = WindowObserver() + + func body(content: Content) -> some View { + content.background( + HostingWindowFinder { [weak windowObserver] window in + windowObserver?.window = window + } + ).environment(\.isKeyWindow, windowObserver.isKeyWindow) + } +} + +extension EnvironmentValues { + struct IsKeyWindowKey: EnvironmentKey { + static var defaultValue: Bool = false + typealias Value = Bool + } + + fileprivate(set) var isKeyWindow: Bool { + get { + self[IsKeyWindowKey.self] + } + set { + self[IsKeyWindowKey.self] = newValue + } + } +} + +class WindowObserver: ObservableObject { + @Published public private(set) var isKeyWindow: Bool = false + + private var becomeKeyobserver: NSObjectProtocol? + private var resignKeyobserver: NSObjectProtocol? + + weak var window: NSWindow? { + didSet { + self.isKeyWindow = window?.isKeyWindow ?? false + guard let window = window else { + self.becomeKeyobserver = nil + self.resignKeyobserver = nil + return + } + + self.becomeKeyobserver = NotificationCenter.default.addObserver( + forName: NSWindow.didBecomeKeyNotification, + object: window, + queue: .main + ) { (n) in + self.isKeyWindow = true + } + + self.resignKeyobserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { (n) in + self.isKeyWindow = false + } + } + } +} + +/// This view calls the callback with the window value that hosts the view. +struct HostingWindowFinder: NSViewRepresentable { + var callback: (NSWindow?) -> () + + func makeNSView(context: Self.Context) -> NSView { + let view = NSView() + view.translatesAutoresizingMaskIntoConstraints = false + DispatchQueue.main.async { [weak view] in + self.callback(view?.window) + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/src/App.zig b/src/App.zig index 3a6e50971..948e409c1 100644 --- a/src/App.zig +++ b/src/App.zig @@ -414,6 +414,11 @@ pub const CAPI = struct { win.window.updateContentScale(x, y); } + /// Update the focused state of a surface. + export fn ghostty_surface_set_focus(win: *Window, focused: bool) void { + win.window.focusCallback(focused); + } + /// Tell the surface that it needs to schedule a render export fn ghostty_surface_key( win: *Window, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index aff88477e..a4ddb90a7 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -167,4 +167,11 @@ pub const Window = struct { return; }; } + + pub fn focusCallback(self: *const Window, focused: bool) void { + self.core_win.focusCallback(focused) catch |err| { + log.err("error in focus callback err={}", .{err}); + return; + }; + } };