From 4dd9fe5cfd53bd60760ce74225c2065625594a89 Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Tue, 7 Jan 2025 22:54:02 +0100 Subject: [PATCH 1/4] fix: ensure terminal tabs are reconstructed in main window after toggling visibility --- macos/Sources/App/macOS/AppDelegate.swift | 38 +++++++++++++++---- .../Features/Terminal/TerminalManager.swift | 2 +- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index a102beb91..eb9734f6c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -706,20 +706,42 @@ class AppDelegate: NSObject, /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application @IBAction func toggleVisibility(_ sender: Any) { - // We only care about terminal windows. - for window in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { - if isVisible { - window.orderOut(nil) - } else { - window.makeKeyAndOrderFront(nil) + if let mainWindow = terminalManager.mainWindow { + guard let parent = mainWindow.controller.window else { + Self.logger.debug("could not get parent window") + return + } + + guard let controller = parent.windowController as? TerminalController, + let primaryWindow = controller.window else { + Self.logger.debug("Could not retrieve primary window") + return + } + + // Fetch all terminal windows controlled by BaseTerminalController + for terminalWindow in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { + if isVisible { + terminalWindow.orderOut(nil) + } else { + primaryWindow.makeKeyAndOrderFront(nil) + primaryWindow.addTabbedWindow(terminalWindow, ordered: .above) + } + } + + // If our parent tab group already has this window, macOS added it and + // we need to remove it so we can set the correct order in the next line. + // If we don't do this, macOS gets really confused and the tabbedWindows + // state becomes incorrect. + if let tg = parent.tabGroup, tg.windows.firstIndex(of: parent) != nil { + tg.removeWindow(parent) } } - + // After bringing them all to front we make sure our app is active too. if !isVisible { NSApp.activate(ignoringOtherApps: true) } - + isVisible.toggle() } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 42e35b90e..82a5978c7 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -26,7 +26,7 @@ class TerminalManager { /// Returns the main window of the managed window stack. If there is no window /// then an arbitrary window will be chosen. - private var mainWindow: Window? { + var mainWindow: Window? { for window in windows { if (window.controller.window?.isMainWindow ?? false) { return window From 3a5aecc216290cb0f5a50da9808d34bb9ae5bec5 Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Thu, 9 Jan 2025 23:14:00 +0100 Subject: [PATCH 2/4] fix: hide windows without calling orderOut API --- macos/Sources/App/macOS/AppDelegate.swift | 33 +++++------------------ 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index eb9734f6c..776ada63e 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -706,34 +706,13 @@ class AppDelegate: NSObject, /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application @IBAction func toggleVisibility(_ sender: Any) { - if let mainWindow = terminalManager.mainWindow { - guard let parent = mainWindow.controller.window else { - Self.logger.debug("could not get parent window") - return + if isVisible { + NSApp.windows.forEach { window in + window.alphaValue = 0.0 } - - guard let controller = parent.windowController as? TerminalController, - let primaryWindow = controller.window else { - Self.logger.debug("Could not retrieve primary window") - return - } - - // Fetch all terminal windows controlled by BaseTerminalController - for terminalWindow in NSApp.windows.filter({ $0.windowController is BaseTerminalController }) { - if isVisible { - terminalWindow.orderOut(nil) - } else { - primaryWindow.makeKeyAndOrderFront(nil) - primaryWindow.addTabbedWindow(terminalWindow, ordered: .above) - } - } - - // If our parent tab group already has this window, macOS added it and - // we need to remove it so we can set the correct order in the next line. - // If we don't do this, macOS gets really confused and the tabbedWindows - // state becomes incorrect. - if let tg = parent.tabGroup, tg.windows.firstIndex(of: parent) != nil { - tg.removeWindow(parent) + } else { + NSApp.windows.forEach { window in + window.alphaValue = 1.0 } } From 61a78efa83d176c2a81e590425f52f962125c34f Mon Sep 17 00:00:00 2001 From: Alexandre Antonio Juca Date: Thu, 9 Jan 2025 23:15:06 +0100 Subject: [PATCH 3/4] chore: revert on TerminalManager changes --- macos/Sources/Features/Terminal/TerminalManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 82a5978c7..42e35b90e 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -26,7 +26,7 @@ class TerminalManager { /// Returns the main window of the managed window stack. If there is no window /// then an arbitrary window will be chosen. - var mainWindow: Window? { + private var mainWindow: Window? { for window in windows { if (window.controller.window?.isMainWindow ?? false) { return window From 200aee9acf0a4b4ec4d4f57cde12443cef257448 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jan 2025 14:35:43 -0800 Subject: [PATCH 4/4] macos: rework toggle_visibility to better match iTerm2 Two major changes: 1. Hiding uses `NSApp.hide` which hides all windows, preserves tabs, and yields focus to the next app. 2. Unhiding manually tracks and brings forward only the windows we hid. Proper focus should be retained. --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ macos/Sources/App/macOS/AppDelegate.swift | 58 ++++++++++++----------- macos/Sources/Helpers/Weak.swift | 9 ++++ src/input/Binding.zig | 6 +-- 4 files changed, 47 insertions(+), 30 deletions(-) create mode 100644 macos/Sources/Helpers/Weak.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 3fa67c48a..efa4a07c9 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5A6F7292CC41B8700B232A5 /* Xcode.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CA378C2D2A4DEB00931030 /* KeyboardLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */; }; + A5CA378E2D31D6C300931030 /* Weak.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CA378D2D31D6C100931030 /* Weak.swift */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; @@ -167,6 +168,7 @@ A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CA378B2D2A4DE800931030 /* KeyboardLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyboardLayout.swift; sourceTree = ""; }; + A5CA378D2D31D6C100931030 /* Weak.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Weak.swift; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = ""; }; @@ -282,6 +284,7 @@ AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, A5CC36142C9CDA03004D6760 /* View+Extension.swift */, + A5CA378D2D31D6C100931030 /* Weak.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, A5CEAFDA29B8005900646FDA /* SplitView */, @@ -647,6 +650,7 @@ A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */, A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, + A5CA378E2D31D6C300931030 /* Weak.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 776ada63e..4b11b68aa 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -92,10 +92,8 @@ class AppDelegate: NSObject, return ProcessInfo.processInfo.systemUptime - applicationLaunchTime } - /// Tracks whether the application is currently visible. This can be gamed, i.e. if a user manually - /// brings each window one by one to the front. But at worst its off by one set of toggles and this - /// makes our logic very easy. - private var isVisible: Bool = true + /// Tracks the windows that we hid for toggleVisibility. + private var hiddenWindows: [Weak] = [] /// The observer for the app appearance. private var appearanceObserver: NSKeyValueObservation? = nil @@ -219,15 +217,20 @@ class AppDelegate: NSObject, } func applicationDidBecomeActive(_ notification: Notification) { - guard !applicationHasBecomeActive else { return } - applicationHasBecomeActive = true + // If we're back then clear the hidden windows + self.hiddenWindows = [] - // Let's launch our first window. We only do this if we have no other windows. It - // is possible to have other windows in a few scenarios: - // - if we're opening a URL since `application(_:openFile:)` is called before this. - // - if we're restoring from persisted state - if terminalManager.windows.count == 0 && derivedConfig.initialWindow { - terminalManager.newWindow() + // First launch stuff + if (!applicationHasBecomeActive) { + applicationHasBecomeActive = true + + // Let's launch our first window. We only do this if we have no other windows. It + // is possible to have other windows in a few scenarios: + // - if we're opening a URL since `application(_:openFile:)` is called before this. + // - if we're restoring from persisted state + if terminalManager.windows.count == 0 && derivedConfig.initialWindow { + terminalManager.newWindow() + } } } @@ -706,22 +709,23 @@ class AppDelegate: NSObject, /// Toggles visibility of all Ghosty Terminal windows. When hidden, activates Ghostty as the frontmost application @IBAction func toggleVisibility(_ sender: Any) { - if isVisible { - NSApp.windows.forEach { window in - window.alphaValue = 0.0 - } - } else { - NSApp.windows.forEach { window in - window.alphaValue = 1.0 - } + // If we have focus, then we hide all windows. + if NSApp.isActive { + // We need to keep track of the windows that were visible because we only + // want to bring back these windows if we remove the toggle. + self.hiddenWindows = NSApp.windows.filter { $0.isVisible }.map { Weak($0) } + NSApp.hide(nil) + return } - - // After bringing them all to front we make sure our app is active too. - if !isVisible { - NSApp.activate(ignoringOtherApps: true) - } - - isVisible.toggle() + + // If we're not active, we want to become active + NSApp.activate(ignoringOtherApps: true) + + // Bring all windows to the front. Note: we don't use NSApp.unhide because + // that will unhide ALL hidden windows. We want to only bring forward the + // ones that we hid. + self.hiddenWindows.forEach { $0.value?.orderFrontRegardless() } + self.hiddenWindows = [] } private struct DerivedConfig { diff --git a/macos/Sources/Helpers/Weak.swift b/macos/Sources/Helpers/Weak.swift new file mode 100644 index 000000000..d5f784844 --- /dev/null +++ b/macos/Sources/Helpers/Weak.swift @@ -0,0 +1,9 @@ +/// A wrapper that holds a weak reference to an object. This lets us create native containers +/// of weak references. +class Weak { + weak var value: T? + + init(_ value: T) { + self.value = value + } +} diff --git a/src/input/Binding.zig b/src/input/Binding.zig index c5faaad06..2fdbc4cba 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -441,10 +441,10 @@ pub const Action = union(enum) { toggle_quick_terminal: void, /// Show/hide all windows. If all windows become shown, we also ensure - /// Ghostty is focused. + /// Ghostty becomes focused. When hiding all windows, focus is yielded + /// to the next application as determined by the OS. /// - /// This currently only works on macOS. When hiding all windows, we do - /// not yield focus to the previous application. + /// This currently only works on macOS. toggle_visibility: void, /// Quit ghostty.