From 93b2fe60f828539adb3877a904bc20672d5ca597 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 14:44:57 -0700 Subject: [PATCH 01/20] macos: start work on SlideTerminal, slides in window from top --- macos/Ghostty.xcodeproj/project.pbxproj | 20 ++++++ macos/Sources/App/macOS/AppDelegate.swift | 7 ++- .../Features/SlideTerminal/SlideTerminal.xib | 31 ++++++++++ .../SlideTerminalController.swift | 62 +++++++++++++++++++ .../SlideTerminal/SlideTerminalWindow.swift | 39 ++++++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Features/SlideTerminal/SlideTerminal.xib create mode 100644 macos/Sources/Features/SlideTerminal/SlideTerminalController.swift create mode 100644 macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 9bff35757..f05c8e74c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -62,6 +62,9 @@ A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; }; + A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */; }; + A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */; }; + A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -133,6 +136,9 @@ A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; + A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SlideTerminal.xib; sourceTree = ""; }; + A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalController.swift; sourceTree = ""; }; + A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalWindow.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -204,6 +210,7 @@ A5CBD0672CA2704E0017A1AE /* Global Keybinds */, A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, + A5CBD05A2CA0C5910017A1AE /* SlideTerminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, A534263E2A7DCC5800EBB7A2 /* Settings */, @@ -381,6 +388,16 @@ path = "Global Keybinds"; sourceTree = ""; }; + A5CBD05A2CA0C5910017A1AE /* SlideTerminal */ = { + isa = PBXGroup; + children = ( + A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */, + A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */, + A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */, + ); + path = SlideTerminal; + sourceTree = ""; + }; A5CEAFDA29B8005900646FDA /* SplitView */ = { isa = PBXGroup; children = ( @@ -506,6 +523,7 @@ A5985CE62C33060F00C57AD3 /* man in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, 552964E62B34A9B400030505 /* vim in Resources */, + A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -533,6 +551,8 @@ A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, + A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */, + A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index fc24345a8..5e520df00 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -156,6 +156,8 @@ class AppDelegate: NSObject, center.delegate = self } + var foo: SlideTerminalController? = nil + func applicationDidBecomeActive(_ notification: Notification) { guard !applicationHasBecomeActive else { return } applicationHasBecomeActive = true @@ -165,8 +167,11 @@ class AppDelegate: NSObject, // - 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 { - terminalManager.newWindow() + //terminalManager.newWindow() } + + foo = SlideTerminalController(window: nil) + foo?.showWindow(self) } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminal.xib b/macos/Sources/Features/SlideTerminal/SlideTerminal.xib new file mode 100644 index 000000000..4bb068a7e --- /dev/null +++ b/macos/Sources/Features/SlideTerminal/SlideTerminal.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift new file mode 100644 index 000000000..4bd183508 --- /dev/null +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -0,0 +1,62 @@ +import Foundation +import Cocoa +import SwiftUI +import GhosttyKit + +/// Controller for the slide-style terminal. +class SlideTerminalController: NSWindowController { + override var windowNibName: NSNib.Name? { "SlideTerminal" } + + override func windowDidLoad() { + guard let window = self.window else { return } + + // Make the window full width + let screenFrame = NSScreen.main?.frame ?? .zero + window.setFrame(NSRect( + x: 0, + y: 0, + width: screenFrame.size.width, + height: window.frame.size.height + ), display: false) + + slideWindowIn(window: window) + } + + private func slideWindowIn(window: NSWindow) { + guard let screen = NSScreen.main else { return } + + // Determine our final position. Our final position is exactly + // pinned against the top menu bar. + let windowFrame = window.frame + let finalY = screen.visibleFrame.maxY - windowFrame.height + + // Move our window off screen to the top + window.setFrameOrigin(.init( + x: windowFrame.origin.x, + y: screen.frame.maxY)) + + // Set the window invisible + window.alphaValue = 0 + + // Move it to the visible position since animation requires this + window.makeKeyAndOrderFront(nil) + + // Run the animation that moves our window into the proper place and makes + // it visible. + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + context.timingFunction = .init(name: .easeIn) + + let animator = window.animator() + animator.setFrame(.init( + origin: .init(x: windowFrame.origin.x, y: finalY), + size: windowFrame.size + ), display: true) + animator.alphaValue = 1 + } + } +} + +enum SlideTerminalLocation { + case top +} diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift new file mode 100644 index 000000000..f2eac6b4d --- /dev/null +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift @@ -0,0 +1,39 @@ +import Cocoa + +class SlideTerminalWindow: NSWindow { + // Both of these must be true for windows without decorations to be able to + // still become key/main and receive events. + override var canBecomeKey: Bool { return true } + override var canBecomeMain: Bool { return true } + + override func awakeFromNib() { + super.awakeFromNib() + + // Note: almost all of this stuff can be done in the nib/xib directly + // but I prefer to do it programmatically because the properties we + // care about are less hidden. + + // Remove the title completely. This will make the window square. One + // downside is it also hides the cursor indications of resize but the + // window remains resizable. + self.styleMask.remove(.titled) + + // We need to set our window level to a high value. In testing, only + // popUpMenu and above do what we want. This gets it above the menu bar + // and lets us render off screen. + self.level = .popUpMenu + + self.isMovableByWindowBackground = true + self.isMovable = true + + self.collectionBehavior = [ + // We want this to be part of every space because it is a singleton. + .canJoinAllSpaces, + + // We don't want to be part of command-tilde + .ignoresCycle, + + // We never support fullscreen + .fullScreenNone] + } +} From bdd0070ffdb0816991c1f7c6d9e66d2504295702 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 20:52:04 -0700 Subject: [PATCH 02/20] macos: render a terminal in the slide window --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + macos/Sources/App/macOS/AppDelegate.swift | 2 +- .../SlideTerminalController.swift | 94 ++++++++++++------- .../SlideTerminal/SlideTerminalPosition.swift | 41 ++++++++ .../SlideTerminal/SlideTerminalWindow.swift | 3 - .../Features/Terminal/TerminalView.swift | 1 + 6 files changed, 108 insertions(+), 37 deletions(-) create mode 100644 macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index f05c8e74c..450a994a7 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */; }; A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */; }; A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */; }; + A5CBD0642CA122E70017A1AE /* SlideTerminalPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -139,6 +140,7 @@ A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SlideTerminal.xib; sourceTree = ""; }; A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalController.swift; sourceTree = ""; }; A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalWindow.swift; sourceTree = ""; }; + A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalPosition.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -393,6 +395,7 @@ children = ( A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */, A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */, + A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */, A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */, ); path = SlideTerminal; @@ -548,6 +551,7 @@ A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, + A5CBD0642CA122E70017A1AE /* SlideTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 5e520df00..1611541d1 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -170,7 +170,7 @@ class AppDelegate: NSObject, //terminalManager.newWindow() } - foo = SlideTerminalController(window: nil) + foo = SlideTerminalController(ghostty, baseConfig: nil) foo?.showWindow(self) } diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 4bd183508..3db695eab 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -4,39 +4,77 @@ import SwiftUI import GhosttyKit /// Controller for the slide-style terminal. -class SlideTerminalController: NSWindowController { +class SlideTerminalController: NSWindowController, TerminalViewDelegate, TerminalViewModel { override var windowNibName: NSNib.Name? { "SlideTerminal" } + /// The app instance that this terminal view will represent. + let ghostty: Ghostty.App + + /// The position fo the slide terminal. + let position: SlideTerminalPosition + + /// The surface tree for this window. + @Published var surfaceTree: Ghostty.SplitNode? = nil + + init(_ ghostty: Ghostty.App, + position: SlideTerminalPosition = .top, + baseConfig base: Ghostty.SurfaceConfiguration? = nil, + surfaceTree tree: Ghostty.SplitNode? = nil + ) { + self.ghostty = ghostty + self.position = position + + super.init(window: nil) + + // Initialize our initial surface. + guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } + self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported for this view") + } + + // MARK: NSWindowController + override func windowDidLoad() { guard let window = self.window else { return } - // Make the window full width - let screenFrame = NSScreen.main?.frame ?? .zero - window.setFrame(NSRect( - x: 0, - y: 0, - width: screenFrame.size.width, - height: window.frame.size.height - ), display: false) + // The slide window is not restorable (yet!). "Yet" because in theory we can + // make this restorable, but it isn't currently implemented. + window.isRestorable = false - slideWindowIn(window: window) + // Setup our content + window.contentView = NSHostingView(rootView: TerminalView( + ghostty: self.ghostty, + viewModel: self, + delegate: self + )) + + // Animate the window in + slideWindowIn(window: window, from: position) } - private func slideWindowIn(window: NSWindow) { + //MARK: TerminalViewDelegate + + func cellSizeDidChange(to: NSSize) { + guard ghostty.config.windowStepResize else { return } + self.window?.contentResizeIncrements = to + } + + func surfaceTreeDidChange() { + if (surfaceTree == nil) { + self.window?.close() + } + } + + // MARK: Slide Logic + + private func slideWindowIn(window: NSWindow, from position: SlideTerminalPosition) { guard let screen = NSScreen.main else { return } - // Determine our final position. Our final position is exactly - // pinned against the top menu bar. - let windowFrame = window.frame - let finalY = screen.visibleFrame.maxY - windowFrame.height - // Move our window off screen to the top - window.setFrameOrigin(.init( - x: windowFrame.origin.x, - y: screen.frame.maxY)) - - // Set the window invisible - window.alphaValue = 0 + position.setInitial(in: window, on: screen) // Move it to the visible position since animation requires this window.makeKeyAndOrderFront(nil) @@ -46,17 +84,7 @@ class SlideTerminalController: NSWindowController { NSAnimationContext.runAnimationGroup { context in context.duration = 0.3 context.timingFunction = .init(name: .easeIn) - - let animator = window.animator() - animator.setFrame(.init( - origin: .init(x: windowFrame.origin.x, y: finalY), - size: windowFrame.size - ), display: true) - animator.alphaValue = 1 + position.setFinal(in: window.animator(), on: screen) } } } - -enum SlideTerminalLocation { - case top -} diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift new file mode 100644 index 000000000..89c521a47 --- /dev/null +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift @@ -0,0 +1,41 @@ +import Cocoa + +enum SlideTerminalPosition { + case top + + /// Set the initial state for a window for animating out of this position. + func setInitial(in window: NSWindow, on screen: NSScreen) { + // We always start invisible + window.alphaValue = 0 + + // Position depends + switch (self) { + case .top: + window.setFrame(.init( + origin: .init( + x: 0, + y: screen.frame.maxY), + size: .init( + width: screen.frame.width, + height: window.frame.height) + ), display: false) + } + } + + /// Set the final state for a window in this position. + func setFinal(in window: NSWindow, on screen: NSScreen) { + // We always end visible + window.alphaValue = 1 + + // Position depends + switch (self) { + case .top: + window.setFrame(.init( + origin: .init( + x: window.frame.origin.x, + y: screen.visibleFrame.maxY - window.frame.height), + size: window.frame.size + ), display: true) + } + } +} diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift index f2eac6b4d..c170b3a8b 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift @@ -23,9 +23,6 @@ class SlideTerminalWindow: NSWindow { // and lets us render off screen. self.level = .popUpMenu - self.isMovableByWindowBackground = true - self.isMovable = true - self.collectionBehavior = [ // We want this to be part of every space because it is a singleton. .canJoinAllSpaces, diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 248c09056..64ce37885 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -18,6 +18,7 @@ protocol TerminalViewDelegate: AnyObject { /// not called initially. func surfaceTreeDidChange() + /// This is called when a split is zoomed. func zoomStateDidChange(to: Bool) } From 63456d28a53bc28cd6e883e4f3695389042a3586 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 21:28:57 -0700 Subject: [PATCH 03/20] macos: make sliding logic a bit more extensible --- .../SlideTerminalController.swift | 35 +++++++++++++++++-- .../SlideTerminal/SlideTerminalPosition.swift | 24 +++++++++---- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 3db695eab..c117ae0f1 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -4,7 +4,7 @@ import SwiftUI import GhosttyKit /// Controller for the slide-style terminal. -class SlideTerminalController: NSWindowController, TerminalViewDelegate, TerminalViewModel { +class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate, TerminalViewModel { override var windowNibName: NSNib.Name? { "SlideTerminal" } /// The app instance that this terminal view will represent. @@ -40,6 +40,10 @@ class SlideTerminalController: NSWindowController, TerminalViewDelegate, Termina override func windowDidLoad() { guard let window = self.window else { return } + // The controller is the window delegate so we can detect events such as + // window close so we can animate out. + window.delegate = self + // The slide window is not restorable (yet!). "Yet" because in theory we can // make this restorable, but it isn't currently implemented. window.isRestorable = false @@ -52,7 +56,13 @@ class SlideTerminalController: NSWindowController, TerminalViewDelegate, Termina )) // Animate the window in - slideWindowIn(window: window, from: position) + slideIn() + } + + // MARK: NSWindowDelegate + + func windowDidResignKey(_ notification: Notification) { + slideOut() } //MARK: TerminalViewDelegate @@ -68,7 +78,17 @@ class SlideTerminalController: NSWindowController, TerminalViewDelegate, Termina } } - // MARK: Slide Logic + // MARK: Slide Methods + + func slideIn() { + guard let window = self.window else { return } + slideWindowIn(window: window, from: position) + } + + func slideOut() { + guard let window = self.window else { return } + slideWindowOut(window: window, to: position) + } private func slideWindowIn(window: NSWindow, from position: SlideTerminalPosition) { guard let screen = NSScreen.main else { return } @@ -87,4 +107,13 @@ class SlideTerminalController: NSWindowController, TerminalViewDelegate, Termina position.setFinal(in: window.animator(), on: screen) } } + + private func slideWindowOut(window: NSWindow, to position: SlideTerminalPosition) { + guard let screen = NSScreen.main else { return } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + context.timingFunction = .init(name: .easeIn) + position.setInitial(in: window.animator(), on: screen) + } + } } diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift index 89c521a47..3ef7d7dcc 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift @@ -12,9 +12,7 @@ enum SlideTerminalPosition { switch (self) { case .top: window.setFrame(.init( - origin: .init( - x: 0, - y: screen.frame.maxY), + origin: initialOrigin(for: window, on: screen), size: .init( width: screen.frame.width, height: window.frame.height) @@ -31,11 +29,25 @@ enum SlideTerminalPosition { switch (self) { case .top: window.setFrame(.init( - origin: .init( - x: window.frame.origin.x, - y: screen.visibleFrame.maxY - window.frame.height), + origin: finalOrigin(for: window, on: screen), size: window.frame.size ), display: true) } } + + /// The initial point origin for this position. + func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { + switch (self) { + case .top: + return .init(x: 0, y: screen.frame.maxY) + } + } + + /// The final point origin for this position. + func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { + switch (self) { + case .top: + return .init(x: window.frame.origin.x, y: screen.visibleFrame.maxY - window.frame.height) + } + } } From d18e1c879bcc68a4c4db68f4cebaa16193b26bdd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 21:35:07 -0700 Subject: [PATCH 04/20] macos: restrict resizing based on sliding terminal position --- .../SlideTerminal/SlideTerminalController.swift | 7 ++++++- .../SlideTerminal/SlideTerminalPosition.swift | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index c117ae0f1..12f785843 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -10,7 +10,7 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie /// The app instance that this terminal view will represent. let ghostty: Ghostty.App - /// The position fo the slide terminal. + /// The position for the slide terminal. let position: SlideTerminalPosition /// The surface tree for this window. @@ -65,6 +65,11 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie slideOut() } + func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { + guard let screen = NSScreen.main else { return frameSize } + return position.restrictFrameSize(frameSize, on: screen) + } + //MARK: TerminalViewDelegate func cellSizeDidChange(to: NSSize) { diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift index 3ef7d7dcc..72f8d9483 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift @@ -35,6 +35,17 @@ enum SlideTerminalPosition { } } + /// Restrict the frame size during resizing. + func restrictFrameSize(_ size: NSSize, on screen: NSScreen) -> NSSize { + var finalSize = size + switch (self) { + case .top: + finalSize.width = screen.frame.width + } + + return finalSize + } + /// The initial point origin for this position. func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { switch (self) { From cadb960ef9b99b8c65af87eaed5fcdf6203dbf69 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 21:57:17 -0700 Subject: [PATCH 05/20] core: slide terminal keybinding action --- macos/Sources/App/macOS/AppDelegate.swift | 21 +++++++++++++------ macos/Sources/App/macOS/MainMenu.xib | 8 +++++++ .../SlideTerminalController.swift | 9 ++++++++ src/Surface.zig | 6 ++++++ src/input/Binding.zig | 15 +++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1611541d1..47302f302 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -49,6 +49,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuIncreaseFontSize: NSMenuItem? @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @IBOutlet private var menuResetFontSize: NSMenuItem? + @IBOutlet private var menuSlideTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuEqualizeSplits: NSMenuItem? @@ -73,6 +74,9 @@ class AppDelegate: NSObject, /// Manages our terminal windows. let terminalManager: TerminalManager + /// Our slide terminal. This starts out uninitialized and only initializes if used. + private var slideController: SlideTerminalController? = nil + /// Manages updates let updaterController: SPUStandardUpdaterController let updaterDelegate: UpdaterDelegate = UpdaterDelegate() @@ -156,8 +160,6 @@ class AppDelegate: NSObject, center.delegate = self } - var foo: SlideTerminalController? = nil - func applicationDidBecomeActive(_ notification: Notification) { guard !applicationHasBecomeActive else { return } applicationHasBecomeActive = true @@ -167,11 +169,8 @@ class AppDelegate: NSObject, // - 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 { - //terminalManager.newWindow() + terminalManager.newWindow() } - - foo = SlideTerminalController(ghostty, baseConfig: nil) - foo?.showWindow(self) } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -315,6 +314,7 @@ class AppDelegate: NSObject, syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) + syncMenuShortcut(action: "toggle_slide_terminal", menuItem: self.menuSlideTerminal) syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput) @@ -550,4 +550,13 @@ class AppDelegate: NSObject, @IBAction func toggleSecureInput(_ sender: Any) { setSecureInput(.toggle) } + + @IBAction func toggleSlideTerminal(_ sender: Any) { + if slideController == nil { + slideController = SlideTerminalController(ghostty, baseConfig: nil) + } + + guard let slideController = self.slideController else { return } + slideController.slideToggle() + } } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index beb411987..f19f9d1ed 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -42,6 +42,7 @@ + @@ -216,6 +217,13 @@ + + + + + + + diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 12f785843..d1c4efc63 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -85,6 +85,15 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie // MARK: Slide Methods + func slideToggle() { + guard let window = self.window else { return } + if (window.alphaValue > 0) { + slideOut() + } else { + slideIn() + } + } + func slideIn() { guard let window = self.window else { return } slideWindowIn(window: window, from: position) diff --git a/src/Surface.zig b/src/Surface.zig index e8bbb885f..007f561e0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3856,6 +3856,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle, ), + .toggle_slide_terminal => { + if (@hasDecl(apprt.Surface, "toggleSlideTerminal")) { + self.rt_surface.toggleSlideTerminal(); + } else log.warn("runtime doesn't implement toggleSlideTerminal", .{}); + }, + .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f9921a87e..73fb6aa47 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -363,6 +363,17 @@ pub const Action = union(enum) { /// This only works on macOS, since this is a system API on macOS. toggle_secure_input: void, + /// Toggle the "slide" terminal. The slide terminal is a terminal that + /// slides in from some screen edge, usually the top. This is useful for + /// quick access to a terminal without having to open a new window or tab. + /// + /// The slide terminal is a singleton; only one instance can exist at a + /// time. + /// + /// See the various configurations for the slide terminal in the + /// configuration file to customize its behavior. + toggle_slide_terminal: void, + /// Quit ghostty. quit: void, @@ -382,6 +393,10 @@ pub const Action = union(enum) { /// crash: CrashThread, + pub const SlideTerminalPosition = enum { + top, + }; + pub const CrashThread = enum { main, io, From bdc2c02f23376c1f9532614959ca866a336fdc31 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 22:08:13 -0700 Subject: [PATCH 06/20] macos: when sliding out the terminal, cycle focus --- .../SlideTerminalController.swift | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index d1c4efc63..7e0ddebdc 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -124,10 +124,41 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie private func slideWindowOut(window: NSWindow, to position: SlideTerminalPosition) { guard let screen = NSScreen.main else { return } - NSAnimationContext.runAnimationGroup { context in + + // Keep track of if we were the key window. If we were the key window then we + // want to move focus to the next window so that focus is preserved somewhere + // in the app. + let wasKey = window.isKeyWindow + + NSAnimationContext.runAnimationGroup({ context in context.duration = 0.3 context.timingFunction = .init(name: .easeIn) position.setInitial(in: window.animator(), on: screen) + }, completionHandler: { + guard wasKey else { return } + self.focusNextWindow() + }) + } + + private func focusNextWindow() { + // We only want to consider windows that are visible + let windows = NSApp.windows.filter { $0.isVisible } + + // If we have no windows there is nothing to focus. + guard !windows.isEmpty else { return } + + // Find the current key window (the window that is currently focused) + if let keyWindow = NSApp.keyWindow, + let currentIndex = windows.firstIndex(of: keyWindow) { + // Calculate the index of the next window (cycle through the list) + let nextIndex = (currentIndex + 1) % windows.count + let nextWindow = windows[nextIndex] + + // Make the next window key and bring it to the front + nextWindow.makeKeyAndOrderFront(nil) + } else { + // If there's no key window, focus the first available window + windows.first?.makeKeyAndOrderFront(nil) } } } From 7806366eec8d631d97c42d05210bad39a8c8eaaf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Sep 2024 09:48:47 -0700 Subject: [PATCH 07/20] core: fix up toggle_slide_terminal action for rebase --- include/ghostty.h | 1 + src/App.zig | 1 + src/Surface.zig | 6 ------ src/apprt/action.zig | 4 ++++ src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 1 + src/input/Binding.zig | 1 + 7 files changed, 9 insertions(+), 6 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index b5dd1609b..38affd16e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -507,6 +507,7 @@ typedef enum { GHOSTTY_ACTION_TOGGLE_FULLSCREEN, GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, + GHOSTTY_ACTION_TOGGLE_SLIDE_TERMINAL, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, GHOSTTY_ACTION_RESIZE_SPLIT, diff --git a/src/App.zig b/src/App.zig index 2e8ac3cf6..369fc4288 100644 --- a/src/App.zig +++ b/src/App.zig @@ -324,6 +324,7 @@ pub fn performAction( .open_config => try rt_app.performAction(.app, .open_config, {}), .reload_config => try self.reloadConfig(rt_app), .close_all_windows => try rt_app.performAction(.app, .close_all_windows, {}), + .toggle_slide_terminal => try rt_app.performAction(.app, .toggle_slide_terminal, {}), } } diff --git a/src/Surface.zig b/src/Surface.zig index 007f561e0..e8bbb885f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3856,12 +3856,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle, ), - .toggle_slide_terminal => { - if (@hasDecl(apprt.Surface, "toggleSlideTerminal")) { - self.rt_surface.toggleSlideTerminal(); - } else log.warn("runtime doesn't implement toggleSlideTerminal", .{}); - }, - .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 9ed89b5a3..6fd15ec9c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -93,6 +93,9 @@ pub const Action = union(Key) { /// Toggle whether window directions are shown. toggle_window_decorations, + /// Toggle the slide terminal in or out. + toggle_slide_terminal, + /// Jump to a specific tab. Must handle the scenario that the tab /// value is invalid. goto_tab: GotoTab, @@ -176,6 +179,7 @@ pub const Action = union(Key) { toggle_fullscreen, toggle_tab_overview, toggle_window_decorations, + toggle_slide_terminal, goto_tab, goto_split, resize_split, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index fb31f7c2b..a64ed0afc 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -196,6 +196,7 @@ pub const App = struct { .close_all_windows, .toggle_tab_overview, .toggle_window_decorations, + .toggle_slide_terminal, .goto_tab, .inspector, .render_inspector, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 94fae8015..dc535868e 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -379,6 +379,7 @@ pub fn performAction( // Unimplemented .close_all_windows, .toggle_split_zoom, + .toggle_slide_terminal, .size_limit, .cell_size, .secure_input, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 73fb6aa47..986b9e7c8 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -578,6 +578,7 @@ pub const Action = union(enum) { .reload_config, .close_all_windows, .quit, + .toggle_slide_terminal, => .app, // These are app but can be special-cased in a surface context. From 99e5e594914a23adbf1815b47b04289769f85482 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Sep 2024 13:39:25 -0700 Subject: [PATCH 08/20] macos: hook up the action for the slide terminal --- macos/Sources/Ghostty/Ghostty.App.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 5b2efad3e..f1e35cb99 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -482,6 +482,9 @@ extension Ghostty { case GHOSTTY_ACTION_RENDERER_HEALTH: rendererHealth(app, target: target, v: action.action.renderer_health) + case GHOSTTY_ACTION_TOGGLE_SLIDE_TERMINAL: + toggleSlideTerminal(app, target: target) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -830,6 +833,14 @@ extension Ghostty { } } + private static func toggleSlideTerminal( + _ app: ghostty_app_t, + target: ghostty_target_s + ) { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + appDelegate.toggleSlideTerminal(self) + } + private static func setTitle( _ app: ghostty_app_t, target: ghostty_target_s, From 50fb7331af8f869819b4cbde7a4e8f5451e4e171 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 10:38:35 -0700 Subject: [PATCH 09/20] macos: base class for terminal controller --- macos/Ghostty.xcodeproj/project.pbxproj | 24 +- .../SlideTerminalController.swift | 31 +- .../Terminal/BaseTerminalController.swift | 361 ++++++++++++++++++ .../Terminal/TerminalController.swift | 328 ++-------------- .../Features/Terminal/TerminalView.swift | 8 - 5 files changed, 408 insertions(+), 344 deletions(-) create mode 100644 macos/Sources/Features/Terminal/BaseTerminalController.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 450a994a7..ed7b97d07 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; + A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; }; @@ -61,11 +62,11 @@ 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 */; }; - A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; }; A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */; }; A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */; }; A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */; }; A5CBD0642CA122E70017A1AE /* SlideTerminalPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */; }; + A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -109,6 +110,7 @@ A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = ""; }; + A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; @@ -136,11 +138,11 @@ A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; 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 = ""; }; - A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SlideTerminal.xib; sourceTree = ""; }; A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalController.swift; sourceTree = ""; }; A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalWindow.swift; sourceTree = ""; }; A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalPosition.swift; sourceTree = ""; }; + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -342,6 +344,7 @@ A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */, AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, + A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */, ); path = Terminal; sourceTree = ""; @@ -382,14 +385,6 @@ name = Products; sourceTree = ""; }; - A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { - isa = PBXGroup; - children = ( - A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */, - ); - path = "Global Keybinds"; - sourceTree = ""; - }; A5CBD05A2CA0C5910017A1AE /* SlideTerminal */ = { isa = PBXGroup; children = ( @@ -401,6 +396,14 @@ path = SlideTerminal; sourceTree = ""; }; + A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { + isa = PBXGroup; + children = ( + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */, + ); + path = "Global Keybinds"; + sourceTree = ""; + }; A5CEAFDA29B8005900646FDA /* SplitView */ = { isa = PBXGroup; children = ( @@ -547,6 +550,7 @@ files = ( A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, + A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 7e0ddebdc..4b5ec05d3 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -4,31 +4,19 @@ import SwiftUI import GhosttyKit /// Controller for the slide-style terminal. -class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate, TerminalViewModel { +class SlideTerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { "SlideTerminal" } - /// The app instance that this terminal view will represent. - let ghostty: Ghostty.App - /// The position for the slide terminal. let position: SlideTerminalPosition - /// The surface tree for this window. - @Published var surfaceTree: Ghostty.SplitNode? = nil - init(_ ghostty: Ghostty.App, position: SlideTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, surfaceTree tree: Ghostty.SplitNode? = nil ) { - self.ghostty = ghostty self.position = position - - super.init(window: nil) - - // Initialize our initial surface. - guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } - self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + super.init(ghostty, baseConfig: base, surfaceTree: tree) } required init?(coder: NSCoder) { @@ -61,7 +49,8 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie // MARK: NSWindowDelegate - func windowDidResignKey(_ notification: Notification) { + override func windowDidResignKey(_ notification: Notification) { + super.windowDidResignKey(notification) slideOut() } @@ -70,15 +59,13 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie return position.restrictFrameSize(frameSize, on: screen) } - //MARK: TerminalViewDelegate + // MARK: Base Controller Overrides - func cellSizeDidChange(to: NSSize) { - guard ghostty.config.windowStepResize else { return } - self.window?.contentResizeIncrements = to - } + override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + super.surfaceTreeDidChange(from: from, to: to) - func surfaceTreeDidChange() { - if (surfaceTree == nil) { + // If our surface tree is now nil then we close our window. + if (to == nil) { self.window?.close() } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift new file mode 100644 index 000000000..fec30eecb --- /dev/null +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -0,0 +1,361 @@ +import Cocoa +import SwiftUI +import GhosttyKit + +/// A base class for windows that can contain Ghostty windows. This base class implements +/// the bare minimum functionality that every terminal window in Ghostty should implement. +/// +/// Usage: Specify this as the base class of your window controller for the window that contains +/// a terminal. The window controller must also be the window delegate OR the window delegate +/// functions on this base class must be called by your own custom delegate. For the terminal +/// view the TerminalView SwiftUI view must be used and this class is the view model and +/// delegate. +/// +/// Notably, things this class does NOT implement (not exhaustive): +/// +/// - Tabbing, because there are many ways to get tabbed behavior in macOS and we +/// don't want to be opinionated about it. +/// - Fullscreen +/// - Window restoration or save state +/// - Window visual styles (such as titlebar colors) +/// +/// The primary idea of all the behaviors we don't implement here are that subclasses may not +/// want these behaviors. +class BaseTerminalController: NSWindowController, + NSWindowDelegate, + TerminalViewDelegate, + TerminalViewModel, + ClipboardConfirmationViewDelegate +{ + /// The app instance that this terminal view will represent. + let ghostty: Ghostty.App + + /// The currently focused surface. + var focusedSurface: Ghostty.SurfaceView? = nil { + didSet { syncFocusToSurfaceTree() } + } + + /// The surface tree for this window. + @Published var surfaceTree: Ghostty.SplitNode? = nil { + didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } + } + + /// Non-nil when an alert is active so we don't overlap multiple. + private var alert: NSAlert? = nil + + /// The clipboard confirmation window, if shown. + private var clipboardConfirmation: ClipboardConfirmationController? = nil + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported for this view") + } + + init(_ ghostty: Ghostty.App, + baseConfig base: Ghostty.SurfaceConfiguration? = nil, + surfaceTree tree: Ghostty.SplitNode? = nil + ) { + self.ghostty = ghostty + + super.init(window: nil) + + // Initialize our initial surface. + guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } + self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + + // Setup our notifications for behaviors + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(onConfirmClipboardRequest), + name: Ghostty.Notification.confirmClipboard, + object: nil) + } + + /// Called when the surfaceTree variable changed. + /// + /// Subclasses should call super first. + func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + // If our surface tree becomes nil then ensure all surfaces + // in the old tree have closed. + if (to == nil) { + from?.close() + focusedSurface = nil + } + } + + /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about + /// what surface is focused. This must be called whenever a surface OR window changes focus. + func syncFocusToSurfaceTree() { + guard let tree = self.surfaceTree else { return } + + for leaf in tree { + // Our focus state requires that this window is key and our currently + // focused surface is the surface in this leaf. + let focused: Bool = (window?.isKeyWindow ?? false) && + focusedSurface != nil && + leaf.surface == focusedSurface! + leaf.surface.focusDidChange(focused) + } + } + + // MARK: TerminalViewDelegate + + // Note: this is different from surfaceDidTreeChange(from:,to:) because this is called + // when the currently set value changed in place and the from:to: variant is called + // when the variable was set. + func surfaceTreeDidChange() {} + + func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { + focusedSurface = to + } + + func titleDidChange(to: String) { + guard let window else { return } + + // Set the main window title + window.title = to + } + + func cellSizeDidChange(to: NSSize) { + guard ghostty.config.windowStepResize else { return } + self.window?.contentResizeIncrements = to + } + + func zoomStateDidChange(to: Bool) {} + + // MARK: Clipboard Confirmation + + @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard target == self.focusedSurface else { return } + guard let surface = target.surface else { return } + + // We need a window + guard let window = self.window else { return } + + // Check whether we use non-native fullscreen + guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return } + guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return } + guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return } + + // If we already have a clipboard confirmation view up, we ignore this request. + // This shouldn't be possible... + guard self.clipboardConfirmation == nil else { + Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true) + return + } + + // Show our paste confirmation + self.clipboardConfirmation = ClipboardConfirmationController( + surface: surface, + contents: str, + request: request, + state: state, + delegate: self + ) + window.beginSheet(self.clipboardConfirmation!.window!) + } + + func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) { + // End our clipboard confirmation no matter what + guard let cc = self.clipboardConfirmation else { return } + self.clipboardConfirmation = nil + + // Close the sheet + if let ccWindow = cc.window { + window?.endSheet(ccWindow) + } + + switch (request) { + case .osc_52_write: + guard case .confirm = action else { break } + let pb = NSPasteboard.general + pb.declareTypes([.string], owner: nil) + pb.setString(cc.contents, forType: .string) + case .osc_52_read, .paste: + let str: String + switch (action) { + case .cancel: + str = "" + + case .confirm: + str = cc.contents + } + + Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true) + } + } + + //MARK: - NSWindowDelegate + + // This is called when performClose is called on a window (NOT when close() + // is called directly). performClose is called primarily when UI elements such + // as the "red X" are pressed. + func windowShouldClose(_ sender: NSWindow) -> Bool { + // We must have a window. Is it even possible not to? + guard let window = self.window else { return true } + + // If we have no surfaces, close. + guard let node = self.surfaceTree else { return true } + + // If we already have an alert, continue with it + guard alert == nil else { return false } + + // If our surfaces don't require confirmation, close. + if (!node.needsConfirmQuit()) { return true } + + // We require confirmation, so show an alert as long as we aren't already. + let alert = NSAlert() + alert.messageText = "Close Terminal?" + alert.informativeText = "The terminal still has a running process. If you close the " + + "terminal the process will be killed." + alert.addButton(withTitle: "Close the Terminal") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: window, completionHandler: { response in + self.alert = nil + switch (response) { + case .alertFirstButtonReturn: + window.close() + + default: + break + } + }) + + self.alert = alert + + return false + } + + func windowWillClose(_ notification: Notification) { + // I don't know if this is required anymore. We previously had a ref cycle between + // the view and the window so we had to nil this out to break it but I think this + // may now be resolved. We should verify that no memory leaks and we can remove this. + self.window?.contentView = nil + } + + func windowDidBecomeKey(_ notification: Notification) { + // Becoming/losing key means we have to notify our surface(s) that we have focus + // so things like cursors blink, pty events are sent, etc. + self.syncFocusToSurfaceTree() + } + + func windowDidResignKey(_ notification: Notification) { + // Becoming/losing key means we have to notify our surface(s) that we have focus + // so things like cursors blink, pty events are sent, etc. + self.syncFocusToSurfaceTree() + } + + func windowDidChangeOcclusionState(_ notification: Notification) { + guard let surfaceTree = self.surfaceTree else { return } + let visible = self.window?.occlusionState.contains(.visible) ?? false + for leaf in surfaceTree { + if let surface = leaf.surface.surface { + ghostty_surface_set_occlusion(surface, visible) + } + } + } + + // MARK: First Responder + + @IBAction func close(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.requestClose(surface: surface) + } + + @IBAction func closeWindow(_ sender: Any) { + guard let window = window else { return } + window.performClose(sender) + } + + @IBAction func splitRight(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) + } + + @IBAction func splitDown(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_DOWN) + } + + @IBAction func splitZoom(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitToggleZoom(surface: surface) + } + + + @IBAction func splitMoveFocusPrevious(_ sender: Any) { + splitMoveFocus(direction: .previous) + } + + @IBAction func splitMoveFocusNext(_ sender: Any) { + splitMoveFocus(direction: .next) + } + + @IBAction func splitMoveFocusAbove(_ sender: Any) { + splitMoveFocus(direction: .top) + } + + @IBAction func splitMoveFocusBelow(_ sender: Any) { + splitMoveFocus(direction: .bottom) + } + + @IBAction func splitMoveFocusLeft(_ sender: Any) { + splitMoveFocus(direction: .left) + } + + @IBAction func splitMoveFocusRight(_ sender: Any) { + splitMoveFocus(direction: .right) + } + + @IBAction func equalizeSplits(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitEqualize(surface: surface) + } + + @IBAction func moveSplitDividerUp(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .up, amount: 10) + } + + @IBAction func moveSplitDividerDown(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .down, amount: 10) + } + + @IBAction func moveSplitDividerLeft(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .left, amount: 10) + } + + @IBAction func moveSplitDividerRight(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .right, amount: 10) + } + + private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitMoveFocus(surface: surface, direction: direction) + } + + @IBAction func increaseFontSize(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.changeFontSize(surface: surface, .increase(1)) + } + + @IBAction func decreaseFontSize(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.changeFontSize(surface: surface, .decrease(1)) + } + + @IBAction func resetFontSize(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.changeFontSize(surface: surface, .reset) + } + + @objc func resetTerminal(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.resetTerminal(surface: surface) + } +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 25bbd9b94..bb8b5665d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -3,45 +3,14 @@ import Cocoa import SwiftUI import GhosttyKit -/// The terminal controller is an NSWindowController that maps 1:1 to a terminal window. -class TerminalController: NSWindowController, NSWindowDelegate, - TerminalViewDelegate, TerminalViewModel, - ClipboardConfirmationViewDelegate +/// A classic, tabbed terminal experience. +class TerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { "Terminal" } - /// The app instance that this terminal view will represent. - let ghostty: Ghostty.App - - /// The currently focused surface. - var focusedSurface: Ghostty.SurfaceView? = nil { - didSet { - syncFocusToSurfaceTree() - } - } - - /// The surface tree for this window. - @Published var surfaceTree: Ghostty.SplitNode? = nil { - didSet { - // If our surface tree becomes nil then ensure all surfaces - // in the old tree have closed and then close the window. - if (surfaceTree == nil) { - oldValue?.close() - focusedSurface = nil - lastSurfaceDidClose() - } - } - } - /// Fullscreen state management. let fullscreenHandler = FullScreenHandler() - /// True when an alert is active so we don't overlap multiple. - private var alert: NSAlert? = nil - - /// The clipboard confirmation window, if shown. - private var clipboardConfirmation: ClipboardConfirmationController? = nil - /// This is set to true when we care about frame changes. This is a small optimization since /// this controller registers a listener for ALL frame change notifications and this lets us bail /// early if we don't care. @@ -59,8 +28,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: Ghostty.SplitNode? = nil ) { - self.ghostty = ghostty - // The window we manage is not restorable if we've specified a command // to execute. We do this because the restored window is meaningless at the // time of writing this: it'd just restore to a shell in the same directory @@ -68,11 +35,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, // restoration. self.restorable = (base?.command ?? "") == "" - super.init(window: nil) - - // Initialize our initial surface. - guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } - self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + super.init(ghostty, baseConfig: base, surfaceTree: tree) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -86,11 +49,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, selector: #selector(onGotoTab), name: Ghostty.Notification.ghosttyGotoTab, object: nil) - center.addObserver( - self, - selector: #selector(onConfirmClipboardRequest), - name: Ghostty.Notification.confirmClipboard, - object: nil) center.addObserver( self, selector: #selector(onFrameDidChange), @@ -108,6 +66,17 @@ class TerminalController: NSWindowController, NSWindowDelegate, center.removeObserver(self) } + // MARK: Base Controller Overrides + + override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + super.surfaceTreeDidChange(from: from, to: to) + + // If our surface tree is now nil then we close our window. + if (to == nil) { + self.window?.close() + } + } + //MARK: - Methods func configDidReload() { @@ -230,21 +199,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, } } - /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about - /// what surface is focused. This must be called whenever a surface OR window changes focus. - private func syncFocusToSurfaceTree() { - guard let tree = self.surfaceTree else { return } - - for leaf in tree { - // Our focus state requires that this window is key and our currently - // focused surface is the surface in this leaf. - let focused: Bool = (window?.isKeyWindow ?? false) && - focusedSurface != nil && - leaf.surface == focusedSurface! - leaf.surface.focusDidChange(focused) - } - } - //MARK: - NSWindowController override func windowWillLoad() { @@ -397,84 +351,21 @@ class TerminalController: NSWindowController, NSWindowDelegate, //MARK: - NSWindowDelegate - // This is called when performClose is called on a window (NOT when close() - // is called directly). performClose is called primarily when UI elements such - // as the "red X" are pressed. - func windowShouldClose(_ sender: NSWindow) -> Bool { - // We must have a window. Is it even possible not to? - guard let window = self.window else { return true } - - // If we have no surfaces, close. - guard let node = self.surfaceTree else { return true } - - // If we already have an alert, continue with it - guard alert == nil else { return false } - - // If our surfaces don't require confirmation, close. - if (!node.needsConfirmQuit()) { return true } - - // We require confirmation, so show an alert as long as we aren't already. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + - "terminal the process will be killed." - alert.addButton(withTitle: "Close the Terminal") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - self.alert = nil - switch (response) { - case .alertFirstButtonReturn: - window.close() - - default: - break - } - }) - - self.alert = alert - - return false - } - - func windowWillClose(_ notification: Notification) { - // I don't know if this is required anymore. We previously had a ref cycle between - // the view and the window so we had to nil this out to break it but I think this - // may now be resolved. We should verify that no memory leaks and we can remove this. - self.window?.contentView = nil - + override func windowWillClose(_ notification: Notification) { + super.windowWillClose(notification) self.relabelTabs() } - func windowDidBecomeKey(_ notification: Notification) { + override func windowDidBecomeKey(_ notification: Notification) { + super.windowDidBecomeKey(notification) self.relabelTabs() self.fixTabBar() - - // Becoming/losing key means we have to notify our surface(s) that we have focus - // so things like cursors blink, pty events are sent, etc. - self.syncFocusToSurfaceTree() - } - - func windowDidResignKey(_ notification: Notification) { - // Becoming/losing key means we have to notify our surface(s) that we have focus - // so things like cursors blink, pty events are sent, etc. - self.syncFocusToSurfaceTree() } func windowDidMove(_ notification: Notification) { self.fixTabBar() } - func windowDidChangeOcclusionState(_ notification: Notification) { - guard let surfaceTree = self.surfaceTree else { return } - let visible = self.window?.occlusionState.contains(.visible) ?? false - for leaf in surfaceTree { - if let surface = leaf.surface.surface { - ghostty_surface_set_occlusion(surface, visible) - } - } - } - // Called when the window will be encoded. We handle the data encoding here in the // window controller. func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) { @@ -482,7 +373,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, data.encode(with: state) } - //MARK: - First Responder + // MARK: First Responder @IBAction func newWindow(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } @@ -494,12 +385,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, ghostty.newTab(surface: surface) } - @IBAction func close(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.requestClose(surface: surface) - } - - @IBAction func closeWindow(_ sender: Any) { + @IBAction override func closeWindow(_ sender: Any) { guard let window = window else { return } guard let tabGroup = window.tabGroup else { // No tabs, no tab group, just perform a normal close. @@ -549,117 +435,23 @@ class TerminalController: NSWindowController, NSWindowDelegate, }) } - @IBAction func splitRight(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) - } - - @IBAction func splitDown(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_DOWN) - } - - @IBAction func splitZoom(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitToggleZoom(surface: surface) - } - - @IBAction func splitMoveFocusPrevious(_ sender: Any) { - splitMoveFocus(direction: .previous) - } - - @IBAction func splitMoveFocusNext(_ sender: Any) { - splitMoveFocus(direction: .next) - } - - @IBAction func splitMoveFocusAbove(_ sender: Any) { - splitMoveFocus(direction: .top) - } - - @IBAction func splitMoveFocusBelow(_ sender: Any) { - splitMoveFocus(direction: .bottom) - } - - @IBAction func splitMoveFocusLeft(_ sender: Any) { - splitMoveFocus(direction: .left) - } - - @IBAction func splitMoveFocusRight(_ sender: Any) { - splitMoveFocus(direction: .right) - } - - @IBAction func equalizeSplits(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitEqualize(surface: surface) - } - - @IBAction func moveSplitDividerUp(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitResize(surface: surface, direction: .up, amount: 10) - } - - @IBAction func moveSplitDividerDown(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitResize(surface: surface, direction: .down, amount: 10) - } - - @IBAction func moveSplitDividerLeft(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitResize(surface: surface, direction: .left, amount: 10) - } - - @IBAction func moveSplitDividerRight(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitResize(surface: surface, direction: .right, amount: 10) - } - - private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitMoveFocus(surface: surface, direction: direction) - } - @IBAction func toggleGhosttyFullScreen(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleFullscreen(surface: surface) } - @IBAction func increaseFontSize(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.changeFontSize(surface: surface, .increase(1)) - } - - @IBAction func decreaseFontSize(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.changeFontSize(surface: surface, .decrease(1)) - } - - @IBAction func resetFontSize(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.changeFontSize(surface: surface, .reset) - } - @IBAction func toggleTerminalInspector(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleTerminalInspector(surface: surface) } - @objc func resetTerminal(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.resetTerminal(surface: surface) - } - //MARK: - TerminalViewDelegate - func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { - self.focusedSurface = to - } + override func titleDidChange(to: String) { + super.titleDidChange(to: to) - func titleDidChange(to: String) { guard let window = window as? TerminalWindow else { return } - // Set the main window title - window.title = to - // Custom toolbar-based title used when titlebar tabs are enabled. if let toolbar = window.toolbar as? TerminalToolbar { if (window.titlebarTabs || ghostty.config.macosTitlebarStyle == "hidden") { @@ -672,58 +464,17 @@ class TerminalController: NSWindowController, NSWindowDelegate, } } - func cellSizeDidChange(to: NSSize) { - guard ghostty.config.windowStepResize else { return } - self.window?.contentResizeIncrements = to - } - - func lastSurfaceDidClose() { - self.window?.close() - } - - func surfaceTreeDidChange() { + override func surfaceTreeDidChange() { // Whenever our surface tree changes in any way (new split, close split, etc.) // we want to invalidate our state. invalidateRestorableState() } - func zoomStateDidChange(to: Bool) { + override func zoomStateDidChange(to: Bool) { guard let window = window as? TerminalWindow else { return } window.surfaceIsZoomed = to } - //MARK: - Clipboard Confirmation - - func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) { - // End our clipboard confirmation no matter what - guard let cc = self.clipboardConfirmation else { return } - self.clipboardConfirmation = nil - - // Close the sheet - if let ccWindow = cc.window { - window?.endSheet(ccWindow) - } - - switch (request) { - case .osc_52_write: - guard case .confirm = action else { break } - let pb = NSPasteboard.general - pb.declareTypes([.string], owner: nil) - pb.setString(cc.contents, forType: .string) - case .osc_52_read, .paste: - let str: String - switch (action) { - case .cancel: - str = "" - - case .confirm: - str = cc.contents - } - - Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true) - } - } - //MARK: - Notifications @objc private func onGotoTab(notification: SwiftUI.Notification) { @@ -793,35 +544,4 @@ class TerminalController: NSWindowController, NSWindowDelegate, Ghostty.moveFocus(to: focusedSurface) } } - - @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { - guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard target == self.focusedSurface else { return } - guard let surface = target.surface else { return } - - // We need a window - guard let window = self.window else { return } - - // Check whether we use non-native fullscreen - guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return } - guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return } - guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return } - - // If we already have a clipboard confirmation view up, we ignore this request. - // This shouldn't be possible... - guard self.clipboardConfirmation == nil else { - Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true) - return - } - - // Show our paste confirmation - self.clipboardConfirmation = ClipboardConfirmationController( - surface: surface, - contents: str, - request: request, - state: state, - delegate: self - ) - window.beginSheet(self.clipboardConfirmation!.window!) - } } diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 64ce37885..ec7d7c229 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -22,14 +22,6 @@ protocol TerminalViewDelegate: AnyObject { func zoomStateDidChange(to: Bool) } -// Default all the functions so they're optional -extension TerminalViewDelegate { - func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {} - func titleDidChange(to: String) {} - func cellSizeDidChange(to: NSSize) {} - func zoomStateDidChange(to: Bool) {} -} - /// The view model is a required implementation for TerminalView callers. This contains /// the main state between the TerminalView caller and SwiftUI. This abstraction is what /// allows AppKit to own most of the data in SwiftUI. From 1977e220f587a03a70d31d6904ca7faaf3416668 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 10:51:14 -0700 Subject: [PATCH 10/20] macos: slide terminal exit and close window don't kill the window --- .../SlideTerminalController.swift | 20 +++++++++++++++++-- .../Terminal/BaseTerminalController.swift | 4 +++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 4b5ec05d3..5029b22b0 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -64,9 +64,9 @@ class SlideTerminalController: BaseTerminalController { override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { super.surfaceTreeDidChange(from: from, to: to) - // If our surface tree is now nil then we close our window. + // If our surface tree is nil then we slide the window out. if (to == nil) { - self.window?.close() + slideOut() } } @@ -83,7 +83,16 @@ class SlideTerminalController: BaseTerminalController { func slideIn() { guard let window = self.window else { return } + + // Animate the window in slideWindowIn(window: window, from: position) + + // If our surface tree is nil then we initialize a new terminal. The surface + // tree can be nil if for example we run "eixt" in the terminal and force a + // slide out. + if (surfaceTree == nil) { + surfaceTree = .leaf(.init(ghostty.app!, baseConfig: nil)) + } } func slideOut() { @@ -148,4 +157,11 @@ class SlideTerminalController: BaseTerminalController { windows.first?.makeKeyAndOrderFront(nil) } } + + // MARK: First Responder + + @IBAction override func closeWindow(_ sender: Any) { + // Instead of closing the window, we slide it out. + slideOut() + } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index fec30eecb..4417ce9cc 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -229,10 +229,12 @@ class BaseTerminalController: NSWindowController, } func windowWillClose(_ notification: Notification) { + guard let window else { return } + // I don't know if this is required anymore. We previously had a ref cycle between // the view and the window so we had to nil this out to break it but I think this // may now be resolved. We should verify that no memory leaks and we can remove this. - self.window?.contentView = nil + window.contentView = nil } func windowDidBecomeKey(_ notification: Notification) { From e3b340c6d3c4715936de6a587ba1775fd5ab50d5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 15:08:14 -0700 Subject: [PATCH 11/20] macos: set initial terminal dimensions --- .../SlideTerminal/SlideTerminalController.swift | 3 +++ .../SlideTerminal/SlideTerminalPosition.swift | 14 ++++++++++++++ .../SlideTerminal/SlideTerminalWindow.swift | 3 +++ 3 files changed, 20 insertions(+) diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 5029b22b0..07d7d42ed 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -36,6 +36,9 @@ class SlideTerminalController: BaseTerminalController { // make this restorable, but it isn't currently implemented. window.isRestorable = false + // Setup our initial size based on our configured position + position.setLoaded(window) + // Setup our content window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift index 72f8d9483..d65f02038 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift @@ -3,6 +3,20 @@ import Cocoa enum SlideTerminalPosition { case top + /// Set the loaded state for a window. + func setLoaded(_ window: NSWindow) { + guard let screen = window.screen ?? NSScreen.main else { return } + switch (self) { + case .top: + window.setFrame(.init( + origin: window.frame.origin, + size: .init( + width: screen.frame.width, + height: screen.frame.height / 4) + ), display: false) + } + } + /// Set the initial state for a window for animating out of this position. func setInitial(in window: NSWindow, on screen: NSScreen) { // We always start invisible diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift index c170b3a8b..fe3426d2b 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift @@ -23,6 +23,9 @@ class SlideTerminalWindow: NSWindow { // and lets us render off screen. self.level = .popUpMenu + // This plus the level above was what was needed for the animation to work, + // because it gets the window off screen properly. Plus we add some fields + // we just want the behavior of. self.collectionBehavior = [ // We want this to be part of every space because it is a singleton. .canJoinAllSpaces, From 1570ef01a78072ad34f3fab160ed85d180c46465 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 15:20:24 -0700 Subject: [PATCH 12/20] rename slide to quick terminal --- include/ghostty.h | 2 +- macos/Ghostty.xcodeproj/project.pbxproj | 38 +++++++------- macos/Sources/App/macOS/AppDelegate.swift | 18 +++---- macos/Sources/App/macOS/MainMenu.xib | 6 +-- .../QuickTerminal.xib} | 4 +- .../QuickTerminalController.swift} | 50 +++++++++---------- .../QuickTerminalPosition.swift} | 2 +- .../QuickTerminalWindow.swift} | 2 +- macos/Sources/Ghostty/Ghostty.App.swift | 8 +-- src/App.zig | 2 +- src/apprt/action.zig | 6 +-- src/apprt/glfw.zig | 2 +- src/apprt/gtk/App.zig | 2 +- src/input/Binding.zig | 27 ++++++---- 14 files changed, 89 insertions(+), 80 deletions(-) rename macos/Sources/Features/{SlideTerminal/SlideTerminal.xib => QuickTerminal/QuickTerminal.xib} (94%) rename macos/Sources/Features/{SlideTerminal/SlideTerminalController.swift => QuickTerminal/QuickTerminalController.swift} (81%) rename macos/Sources/Features/{SlideTerminal/SlideTerminalPosition.swift => QuickTerminal/QuickTerminalPosition.swift} (98%) rename macos/Sources/Features/{SlideTerminal/SlideTerminalWindow.swift => QuickTerminal/QuickTerminalWindow.swift} (97%) diff --git a/include/ghostty.h b/include/ghostty.h index 38affd16e..e66ce08ea 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -507,7 +507,7 @@ typedef enum { GHOSTTY_ACTION_TOGGLE_FULLSCREEN, GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, - GHOSTTY_ACTION_TOGGLE_SLIDE_TERMINAL, + GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, GHOSTTY_ACTION_RESIZE_SPLIT, diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index ed7b97d07..295de738c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -62,10 +62,10 @@ 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 */; }; - A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */; }; - A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */; }; - A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */; }; - A5CBD0642CA122E70017A1AE /* SlideTerminalPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */; }; + A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */; }; + A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */; }; + A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */; }; + A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */; }; A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; @@ -138,10 +138,10 @@ A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; 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 /* SlideTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SlideTerminal.xib; sourceTree = ""; }; - A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalController.swift; sourceTree = ""; }; - A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalWindow.swift; sourceTree = ""; }; - A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalPosition.swift; sourceTree = ""; }; + A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = ""; }; + A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalController.swift; sourceTree = ""; }; + A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalWindow.swift; sourceTree = ""; }; + A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalPosition.swift; sourceTree = ""; }; A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; @@ -214,7 +214,7 @@ A5CBD0672CA2704E0017A1AE /* Global Keybinds */, A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, - A5CBD05A2CA0C5910017A1AE /* SlideTerminal */, + A5CBD05A2CA0C5910017A1AE /* QuickTerminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, A534263E2A7DCC5800EBB7A2 /* Settings */, @@ -385,15 +385,15 @@ name = Products; sourceTree = ""; }; - A5CBD05A2CA0C5910017A1AE /* SlideTerminal */ = { + A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = { isa = PBXGroup; children = ( - A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */, - A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */, - A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */, - A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */, + A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, + A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, + A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, + A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */, ); - path = SlideTerminal; + path = QuickTerminal; sourceTree = ""; }; A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { @@ -529,7 +529,7 @@ A5985CE62C33060F00C57AD3 /* man in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, 552964E62B34A9B400030505 /* vim in Resources */, - A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */, + A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -555,12 +555,12 @@ A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, - A5CBD0642CA122E70017A1AE /* SlideTerminalPosition.swift in Sources */, + A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, - A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */, - A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */, + A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, + A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 47302f302..3686f7fb8 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -49,7 +49,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuIncreaseFontSize: NSMenuItem? @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @IBOutlet private var menuResetFontSize: NSMenuItem? - @IBOutlet private var menuSlideTerminal: NSMenuItem? + @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuEqualizeSplits: NSMenuItem? @@ -74,8 +74,8 @@ class AppDelegate: NSObject, /// Manages our terminal windows. let terminalManager: TerminalManager - /// Our slide terminal. This starts out uninitialized and only initializes if used. - private var slideController: SlideTerminalController? = nil + /// Our quick terminal. This starts out uninitialized and only initializes if used. + private var quickController: QuickTerminalController? = nil /// Manages updates let updaterController: SPUStandardUpdaterController @@ -314,7 +314,7 @@ class AppDelegate: NSObject, syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) - syncMenuShortcut(action: "toggle_slide_terminal", menuItem: self.menuSlideTerminal) + syncMenuShortcut(action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput) @@ -551,12 +551,12 @@ class AppDelegate: NSObject, setSecureInput(.toggle) } - @IBAction func toggleSlideTerminal(_ sender: Any) { - if slideController == nil { - slideController = SlideTerminalController(ghostty, baseConfig: nil) + @IBAction func toggleQuickTerminal(_ sender: Any) { + if quickController == nil { + quickController = QuickTerminalController(ghostty, baseConfig: nil) } - guard let slideController = self.slideController else { return } - slideController.slideToggle() + guard let quickController = self.quickController else { return } + quickController.toggle() } } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index f19f9d1ed..63aae4c60 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -32,6 +32,7 @@ + @@ -42,7 +43,6 @@ - @@ -217,10 +217,10 @@ - + - + diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminal.xib b/macos/Sources/Features/QuickTerminal/QuickTerminal.xib similarity index 94% rename from macos/Sources/Features/SlideTerminal/SlideTerminal.xib rename to macos/Sources/Features/QuickTerminal/QuickTerminal.xib index 4bb068a7e..b2a99cbf5 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminal.xib +++ b/macos/Sources/Features/QuickTerminal/QuickTerminal.xib @@ -6,14 +6,14 @@ - + - + diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift similarity index 81% rename from macos/Sources/Features/SlideTerminal/SlideTerminalController.swift rename to macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 07d7d42ed..5025e725c 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -3,15 +3,15 @@ import Cocoa import SwiftUI import GhosttyKit -/// Controller for the slide-style terminal. -class SlideTerminalController: BaseTerminalController { - override var windowNibName: NSNib.Name? { "SlideTerminal" } +/// Controller for the "quick" terminal. +class QuickTerminalController: BaseTerminalController { + override var windowNibName: NSNib.Name? { "QuickTerminal" } - /// The position for the slide terminal. - let position: SlideTerminalPosition + /// The position for the quick terminal. + let position: QuickTerminalPosition init(_ ghostty: Ghostty.App, - position: SlideTerminalPosition = .top, + position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, surfaceTree tree: Ghostty.SplitNode? = nil ) { @@ -32,7 +32,7 @@ class SlideTerminalController: BaseTerminalController { // window close so we can animate out. window.delegate = self - // The slide window is not restorable (yet!). "Yet" because in theory we can + // The quick window is not restorable (yet!). "Yet" because in theory we can // make this restorable, but it isn't currently implemented. window.isRestorable = false @@ -47,14 +47,14 @@ class SlideTerminalController: BaseTerminalController { )) // Animate the window in - slideIn() + animateIn() } // MARK: NSWindowDelegate override func windowDidResignKey(_ notification: Notification) { super.windowDidResignKey(notification) - slideOut() + animateOut() } func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { @@ -67,43 +67,43 @@ class SlideTerminalController: BaseTerminalController { override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { super.surfaceTreeDidChange(from: from, to: to) - // If our surface tree is nil then we slide the window out. + // If our surface tree is nil then we animate the window out. if (to == nil) { - slideOut() + animateOut() } } - // MARK: Slide Methods + // MARK: Methods - func slideToggle() { + func toggle() { guard let window = self.window else { return } if (window.alphaValue > 0) { - slideOut() + animateOut() } else { - slideIn() + animateIn() } } - func slideIn() { + func animateIn() { guard let window = self.window else { return } // Animate the window in - slideWindowIn(window: window, from: position) + animateWindowIn(window: window, from: position) // If our surface tree is nil then we initialize a new terminal. The surface - // tree can be nil if for example we run "eixt" in the terminal and force a - // slide out. + // tree can be nil if for example we run "eixt" in the terminal and force + // animate out. if (surfaceTree == nil) { surfaceTree = .leaf(.init(ghostty.app!, baseConfig: nil)) } } - func slideOut() { + func animateOut() { guard let window = self.window else { return } - slideWindowOut(window: window, to: position) + animateWindowOut(window: window, to: position) } - private func slideWindowIn(window: NSWindow, from position: SlideTerminalPosition) { + private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = NSScreen.main else { return } // Move our window off screen to the top @@ -121,7 +121,7 @@ class SlideTerminalController: BaseTerminalController { } } - private func slideWindowOut(window: NSWindow, to position: SlideTerminalPosition) { + private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { guard let screen = NSScreen.main else { return } // Keep track of if we were the key window. If we were the key window then we @@ -164,7 +164,7 @@ class SlideTerminalController: BaseTerminalController { // MARK: First Responder @IBAction override func closeWindow(_ sender: Any) { - // Instead of closing the window, we slide it out. - slideOut() + // Instead of closing the window, we animate it out. + animateOut() } } diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift similarity index 98% rename from macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift rename to macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index d65f02038..c7509c465 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -1,6 +1,6 @@ import Cocoa -enum SlideTerminalPosition { +enum QuickTerminalPosition { case top /// Set the loaded state for a window. diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift similarity index 97% rename from macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift rename to macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift index fe3426d2b..2d9d1df7c 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift @@ -1,6 +1,6 @@ import Cocoa -class SlideTerminalWindow: NSWindow { +class QuickTerminalWindow: NSWindow { // Both of these must be true for windows without decorations to be able to // still become key/main and receive events. override var canBecomeKey: Bool { return true } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index f1e35cb99..05c01a75e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -482,8 +482,8 @@ extension Ghostty { case GHOSTTY_ACTION_RENDERER_HEALTH: rendererHealth(app, target: target, v: action.action.renderer_health) - case GHOSTTY_ACTION_TOGGLE_SLIDE_TERMINAL: - toggleSlideTerminal(app, target: target) + case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL: + toggleQuickTerminal(app, target: target) case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough @@ -833,12 +833,12 @@ extension Ghostty { } } - private static func toggleSlideTerminal( + private static func toggleQuickTerminal( _ app: ghostty_app_t, target: ghostty_target_s ) { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } - appDelegate.toggleSlideTerminal(self) + appDelegate.toggleQuickTerminal(self) } private static func setTitle( diff --git a/src/App.zig b/src/App.zig index 369fc4288..5922528ab 100644 --- a/src/App.zig +++ b/src/App.zig @@ -324,7 +324,7 @@ pub fn performAction( .open_config => try rt_app.performAction(.app, .open_config, {}), .reload_config => try self.reloadConfig(rt_app), .close_all_windows => try rt_app.performAction(.app, .close_all_windows, {}), - .toggle_slide_terminal => try rt_app.performAction(.app, .toggle_slide_terminal, {}), + .toggle_quick_terminal => try rt_app.performAction(.app, .toggle_quick_terminal, {}), } } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 6fd15ec9c..2f7616bc4 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -93,8 +93,8 @@ pub const Action = union(Key) { /// Toggle whether window directions are shown. toggle_window_decorations, - /// Toggle the slide terminal in or out. - toggle_slide_terminal, + /// Toggle the quick terminal in or out. + toggle_quick_terminal, /// Jump to a specific tab. Must handle the scenario that the tab /// value is invalid. @@ -179,7 +179,7 @@ pub const Action = union(Key) { toggle_fullscreen, toggle_tab_overview, toggle_window_decorations, - toggle_slide_terminal, + toggle_quick_terminal, goto_tab, goto_split, resize_split, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index a64ed0afc..87314c0e1 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -196,7 +196,7 @@ pub const App = struct { .close_all_windows, .toggle_tab_overview, .toggle_window_decorations, - .toggle_slide_terminal, + .toggle_quick_terminal, .goto_tab, .inspector, .render_inspector, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index dc535868e..9bbfad94e 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -379,7 +379,7 @@ pub fn performAction( // Unimplemented .close_all_windows, .toggle_split_zoom, - .toggle_slide_terminal, + .toggle_quick_terminal, .size_limit, .cell_size, .secure_input, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 986b9e7c8..36d87ae3e 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -363,16 +363,25 @@ pub const Action = union(enum) { /// This only works on macOS, since this is a system API on macOS. toggle_secure_input: void, - /// Toggle the "slide" terminal. The slide terminal is a terminal that - /// slides in from some screen edge, usually the top. This is useful for - /// quick access to a terminal without having to open a new window or tab. + /// Toggle the "quick" terminal. The quick terminal is a terminal that + /// appears on demand from a keybinding, often sliding in from a screen + /// edge such as the top. This is useful for quick access to a terminal + /// without having to open a new window or tab. /// - /// The slide terminal is a singleton; only one instance can exist at a - /// time. + /// When the quick terminal loses focus, it disappears. The terminal state + /// is preserved between appearances, so you can always press the keybinding + /// to bring it back up. /// - /// See the various configurations for the slide terminal in the + /// Ths quick terminal has some limitations: + /// + /// - It is a singleton; only one instance can exist at a time. + /// - It does not support tabs. + /// - It will not be restored when the application is restarted + /// (for systems that support window restoration). + /// + /// See the various configurations for the quick terminal in the /// configuration file to customize its behavior. - toggle_slide_terminal: void, + toggle_quick_terminal: void, /// Quit ghostty. quit: void, @@ -393,7 +402,7 @@ pub const Action = union(enum) { /// crash: CrashThread, - pub const SlideTerminalPosition = enum { + pub const QuickTerminalPosition = enum { top, }; @@ -578,7 +587,7 @@ pub const Action = union(enum) { .reload_config, .close_all_windows, .quit, - .toggle_slide_terminal, + .toggle_quick_terminal, => .app, // These are app but can be special-cased in a surface context. From 13eb8ac6e20a6e4b3d6cdde5eea8303b9eee2b0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 15:29:47 -0700 Subject: [PATCH 13/20] macos: ability to interrupt animation, track it in menu --- macos/Sources/App/macOS/AppDelegate.swift | 2 ++ .../QuickTerminal/QuickTerminalController.swift | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 3686f7fb8..ad4c9bbda 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -558,5 +558,7 @@ class AppDelegate: NSObject, guard let quickController = self.quickController else { return } quickController.toggle() + + self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off } } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 5025e725c..979af1fca 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -10,6 +10,9 @@ class QuickTerminalController: BaseTerminalController { /// The position for the quick terminal. let position: QuickTerminalPosition + /// The current state of the quick terminal + private(set) var visible: Bool = false + init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, @@ -76,8 +79,7 @@ class QuickTerminalController: BaseTerminalController { // MARK: Methods func toggle() { - guard let window = self.window else { return } - if (window.alphaValue > 0) { + if (visible) { animateOut() } else { animateIn() @@ -87,6 +89,10 @@ class QuickTerminalController: BaseTerminalController { func animateIn() { guard let window = self.window else { return } + // Set our visibility state + guard !visible else { return } + visible = true + // Animate the window in animateWindowIn(window: window, from: position) @@ -100,6 +106,11 @@ class QuickTerminalController: BaseTerminalController { func animateOut() { guard let window = self.window else { return } + + // Set our visibility state + guard visible else { return } + visible = false + animateWindowOut(window: window, to: position) } From 11d5ec7dc1187179f3dc2d20bbcb472d43b2785a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 18:42:13 -0700 Subject: [PATCH 14/20] config: support quick terminal position --- macos/Sources/App/macOS/AppDelegate.swift | 5 +- .../QuickTerminal/QuickTerminalPosition.swift | 62 +++++++++++++------ macos/Sources/Ghostty/Ghostty.Config.swift | 10 +++ src/config/Config.zig | 15 +++++ src/input/Binding.zig | 4 -- 5 files changed, 72 insertions(+), 24 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index ad4c9bbda..5980b8d66 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -553,7 +553,10 @@ class AppDelegate: NSObject, @IBAction func toggleQuickTerminal(_ sender: Any) { if quickController == nil { - quickController = QuickTerminalController(ghostty, baseConfig: nil) + quickController = QuickTerminalController( + ghostty, + position: ghostty.config.quickTerminalPosition + ) } guard let quickController = self.quickController else { return } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index c7509c465..559d7ef88 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -1,19 +1,30 @@ import Cocoa -enum QuickTerminalPosition { +enum QuickTerminalPosition : String { case top + case bottom + case left + case right /// Set the loaded state for a window. func setLoaded(_ window: NSWindow) { guard let screen = window.screen ?? NSScreen.main else { return } switch (self) { - case .top: + case .top, .bottom: window.setFrame(.init( origin: window.frame.origin, size: .init( width: screen.frame.width, height: screen.frame.height / 4) ), display: false) + + case .left, .right: + window.setFrame(.init( + origin: window.frame.origin, + size: .init( + width: screen.frame.width / 4, + height: screen.frame.height) + ), display: false) } } @@ -23,15 +34,10 @@ enum QuickTerminalPosition { window.alphaValue = 0 // Position depends - switch (self) { - case .top: - window.setFrame(.init( - origin: initialOrigin(for: window, on: screen), - size: .init( - width: screen.frame.width, - height: window.frame.height) - ), display: false) - } + window.setFrame(.init( + origin: initialOrigin(for: window, on: screen), + size: window.frame.size + ), display: false) } /// Set the final state for a window in this position. @@ -40,21 +46,21 @@ enum QuickTerminalPosition { window.alphaValue = 1 // Position depends - switch (self) { - case .top: - window.setFrame(.init( - origin: finalOrigin(for: window, on: screen), - size: window.frame.size - ), display: true) - } + window.setFrame(.init( + origin: finalOrigin(for: window, on: screen), + size: window.frame.size + ), display: true) } /// Restrict the frame size during resizing. func restrictFrameSize(_ size: NSSize, on screen: NSScreen) -> NSSize { var finalSize = size switch (self) { - case .top: + case .top, .bottom: finalSize.width = screen.frame.width + + case .left, .right: + finalSize.height = screen.frame.height } return finalSize @@ -65,6 +71,15 @@ enum QuickTerminalPosition { switch (self) { case .top: return .init(x: 0, y: screen.frame.maxY) + + case .bottom: + return .init(x: 0, y: -window.frame.height) + + case .left: + return .init(x: -window.frame.width, y: 0) + + case .right: + return .init(x: screen.frame.maxX, y: 0) } } @@ -73,6 +88,15 @@ enum QuickTerminalPosition { switch (self) { case .top: return .init(x: window.frame.origin.x, y: screen.visibleFrame.maxY - window.frame.height) + + case .bottom: + return .init(x: window.frame.origin.x, y: 0) + + case .left: + return .init(x: 0, y: window.frame.origin.y) + + case .right: + return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y) } } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 7ecd45cc4..441172ea0 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -332,6 +332,16 @@ extension Ghostty { return Color(newColor) } + var quickTerminalPosition: QuickTerminalPosition { + guard let config = self.config else { return .top } + var v: UnsafePointer? = nil + let key = "quick-terminal-position" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .top } + guard let ptr = v else { return .top } + let str = String(cString: ptr) + return QuickTerminalPosition(rawValue: str) ?? .top + } + var resizeOverlay: ResizeOverlay { guard let config = self.config else { return .after_first } var v: UnsafePointer? = nil diff --git a/src/config/Config.zig b/src/config/Config.zig index efa741307..6a0818095 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1220,6 +1220,13 @@ keybind: Keybinds = .{}, /// window is ever created. Only implemented on Linux. @"initial-window": bool = true, +/// The position of the "quick" terminal window. To learn more about the +/// quick terminal, see the documentation for the `toggle_quick_terminal` +/// binding action. +/// +/// Changing this configuration requires restarting Ghostty completely. +@"quick-terminal-position": QuickTerminalPosition = .top, + /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: /// @@ -4401,6 +4408,14 @@ pub const ResizeOverlayPosition = enum { @"bottom-right", }; +/// See quick-terminal-position +pub const QuickTerminalPosition = enum { + top, + bottom, + left, + right, +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 36d87ae3e..bef2e2209 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -402,10 +402,6 @@ pub const Action = union(enum) { /// crash: CrashThread, - pub const QuickTerminalPosition = enum { - top, - }; - pub const CrashThread = enum { main, io, From 61dd395251ef57c1bd58254e1a6244e172443a4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 18:46:45 -0700 Subject: [PATCH 15/20] macos: show alert if new tab is attempted from quick term --- .../QuickTerminal/QuickTerminalController.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 979af1fca..698be98bc 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -57,6 +57,11 @@ class QuickTerminalController: BaseTerminalController { override func windowDidResignKey(_ notification: Notification) { super.windowDidResignKey(notification) + + // We don't animate out if there is a modal sheet being shown currently. + // This lets us show alerts without causing the window to disappear. + guard window?.attachedSheet == nil else { return } + animateOut() } @@ -178,4 +183,14 @@ class QuickTerminalController: BaseTerminalController { // Instead of closing the window, we animate it out. animateOut() } + + @IBAction func newTab(_ sender: Any?) { + guard let window else { return } + let alert = NSAlert() + alert.messageText = "Cannot Create New Tab" + alert.informativeText = "Tabs aren't supported in the Quick Terminal." + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.beginSheetModal(for: window) + } } From 1f3c3dde1017a5d37a462fe078c8c5749ca1ee55 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 18:49:52 -0700 Subject: [PATCH 16/20] input: note fullscreen isn't supported by quick terminal --- src/config/Config.zig | 7 +++++++ src/input/Binding.zig | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6a0818095..c950cf807 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1224,6 +1224,13 @@ keybind: Keybinds = .{}, /// quick terminal, see the documentation for the `toggle_quick_terminal` /// binding action. /// +/// Valid values are: +/// +/// * `top` - Terminal appears at the top of the screen. +/// * `bottom` - Terminal appears at the bottom of the screen. +/// * `left` - Terminal appears at the left of the screen. +/// * `right` - Terminal appears at the right of the screen. +/// /// Changing this configuration requires restarting Ghostty completely. @"quick-terminal-position": QuickTerminalPosition = .top, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index bef2e2209..5df3ae8e4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -372,10 +372,11 @@ pub const Action = union(enum) { /// is preserved between appearances, so you can always press the keybinding /// to bring it back up. /// - /// Ths quick terminal has some limitations: + /// The quick terminal has some limitations: /// /// - It is a singleton; only one instance can exist at a time. /// - It does not support tabs. + /// - It does not support fullscreen. /// - It will not be restored when the application is restarted /// (for systems that support window restoration). /// From 1d727320b47d9d6884d006c8e6bc42e079dcf3d8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 19:11:04 -0700 Subject: [PATCH 17/20] macos: if initializing new surface tree, move focus to it --- .../QuickTerminal/QuickTerminalController.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 698be98bc..e28bda0fd 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -105,7 +105,19 @@ class QuickTerminalController: BaseTerminalController { // tree can be nil if for example we run "eixt" in the terminal and force // animate out. if (surfaceTree == nil) { - surfaceTree = .leaf(.init(ghostty.app!, baseConfig: nil)) + let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil) + surfaceTree = .leaf(leaf) + focusedSurface = leaf.surface + + // We need to grab first responder but it takes a few loop cycles + // before the view is attached to the window so we do it async. + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { + // We should probably retry here but I was never able to trigger this. + // If this happens though its a crash so let's avoid it. + guard let leafWindow = leaf.surface.window, + leafWindow == window else { return } + window.makeFirstResponder(leaf.surface) + } } } From 76a2041cbf4b160c1aee51a5454aea7a5f11f53e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 19:11:34 -0700 Subject: [PATCH 18/20] macos: make quick terminal animation 0.2 instead of 0.3 --- .../Features/QuickTerminal/QuickTerminalController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index e28bda0fd..a185eabfe 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -143,7 +143,7 @@ class QuickTerminalController: BaseTerminalController { // Run the animation that moves our window into the proper place and makes // it visible. NSAnimationContext.runAnimationGroup { context in - context.duration = 0.3 + context.duration = 0.2 context.timingFunction = .init(name: .easeIn) position.setFinal(in: window.animator(), on: screen) } @@ -158,7 +158,7 @@ class QuickTerminalController: BaseTerminalController { let wasKey = window.isKeyWindow NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.3 + context.duration = 0.2 context.timingFunction = .init(name: .easeIn) position.setInitial(in: window.animator(), on: screen) }, completionHandler: { From bcdbb5899b45ecfde351321845a0059da08b2825 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 19:14:14 -0700 Subject: [PATCH 19/20] macos: only define quick terminal configs for AppKit --- macos/Sources/Ghostty/Ghostty.Config.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 441172ea0..936ac9821 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -332,6 +332,7 @@ extension Ghostty { return Color(newColor) } + #if canImport(AppKit) var quickTerminalPosition: QuickTerminalPosition { guard let config = self.config else { return .top } var v: UnsafePointer? = nil @@ -341,6 +342,7 @@ extension Ghostty { let str = String(cString: ptr) return QuickTerminalPosition(rawValue: str) ?? .top } + #endif var resizeOverlay: ResizeOverlay { guard let config = self.config else { return .after_first } From 4f9d49b380b957fbfa559d5f09349e85cdbc6b1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 20:21:47 -0700 Subject: [PATCH 20/20] macos: handle multiple monitors properly --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ .../QuickTerminalController.swift | 9 +++-- .../QuickTerminal/QuickTerminalPosition.swift | 14 +++---- .../QuickTerminal/QuickTerminalScreen.swift | 37 +++++++++++++++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 10 +++++ src/config/Config.zig | 27 ++++++++++++++ 6 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 295de738c..e3ad5adf3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 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 */; }; + A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52FFF562CA90481000C6A5B /* QuickTerminalScreen.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 */; }; @@ -103,6 +104,7 @@ 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 = ""; }; + A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalScreen.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 = ""; }; @@ -391,6 +393,7 @@ A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, + A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */, A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */, ); path = QuickTerminal; @@ -588,6 +591,7 @@ A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, + A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */, A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index a185eabfe..f5d899e76 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -66,7 +66,9 @@ class QuickTerminalController: BaseTerminalController { } func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { - guard let screen = NSScreen.main else { return frameSize } + // We use the actual screen the window is on for this, since it should + // be on the proper screen. + guard let screen = window?.screen ?? NSScreen.main else { return frameSize } return position.restrictFrameSize(frameSize, on: screen) } @@ -132,7 +134,7 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { - guard let screen = NSScreen.main else { return } + guard let screen = ghostty.config.quickTerminalScreen.screen else { return } // Move our window off screen to the top position.setInitial(in: window, on: screen) @@ -150,7 +152,8 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { - guard let screen = NSScreen.main else { return } + // We always animate out to whatever screen the window is actually on. + guard let screen = window.screen ?? NSScreen.main else { return } // Keep track of if we were the key window. If we were the key window then we // want to move focus to the next window so that focus is preserved somewhere diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index 559d7ef88..51b450700 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -36,7 +36,7 @@ enum QuickTerminalPosition : String { // Position depends window.setFrame(.init( origin: initialOrigin(for: window, on: screen), - size: window.frame.size + size: restrictFrameSize(window.frame.size, on: screen) ), display: false) } @@ -48,7 +48,7 @@ enum QuickTerminalPosition : String { // Position depends window.setFrame(.init( origin: finalOrigin(for: window, on: screen), - size: window.frame.size + size: restrictFrameSize(window.frame.size, on: screen) ), display: true) } @@ -70,10 +70,10 @@ enum QuickTerminalPosition : String { func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { switch (self) { case .top: - return .init(x: 0, y: screen.frame.maxY) + return .init(x: screen.frame.minX, y: screen.frame.maxY) case .bottom: - return .init(x: 0, y: -window.frame.height) + return .init(x: screen.frame.minX, y: -window.frame.height) case .left: return .init(x: -window.frame.width, y: 0) @@ -87,13 +87,13 @@ enum QuickTerminalPosition : String { func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { switch (self) { case .top: - return .init(x: window.frame.origin.x, y: screen.visibleFrame.maxY - window.frame.height) + return .init(x: screen.frame.minX, y: screen.visibleFrame.maxY - window.frame.height) case .bottom: - return .init(x: window.frame.origin.x, y: 0) + return .init(x: screen.frame.minX, y: screen.frame.minY) case .left: - return .init(x: 0, y: window.frame.origin.y) + return .init(x: screen.frame.minX, y: window.frame.origin.y) case .right: return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift new file mode 100644 index 000000000..cd07a6f12 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift @@ -0,0 +1,37 @@ +import Cocoa + +enum QuickTerminalScreen { + case main + case mouse + case menuBar + + init?(fromGhosttyConfig string: String) { + switch (string) { + case "main": + self = .main + + case "mouse": + self = .mouse + + case "macos-menu-bar": + self = .menuBar + + default: + return nil + } + } + + var screen: NSScreen? { + switch (self) { + case .main: + return NSScreen.main + + case .mouse: + let mouseLoc = NSEvent.mouseLocation + return NSScreen.screens.first(where: { $0.frame.contains(mouseLoc) }) + + case .menuBar: + return NSScreen.screens.first + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 936ac9821..76f85d2a3 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -342,6 +342,16 @@ extension Ghostty { let str = String(cString: ptr) return QuickTerminalPosition(rawValue: str) ?? .top } + + var quickTerminalScreen: QuickTerminalScreen { + guard let config = self.config else { return .main } + var v: UnsafePointer? = nil + let key = "quick-terminal-screen" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .main } + guard let ptr = v else { return .main } + let str = String(cString: ptr) + return QuickTerminalScreen(fromGhosttyConfig: str) ?? .main + } #endif var resizeOverlay: ResizeOverlay { diff --git a/src/config/Config.zig b/src/config/Config.zig index c950cf807..0f5e9b81b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1234,6 +1234,26 @@ keybind: Keybinds = .{}, /// Changing this configuration requires restarting Ghostty completely. @"quick-terminal-position": QuickTerminalPosition = .top, +/// The screen where the quick terminal should show up. +/// +/// Valid values are: +/// +/// * `main` - The screen that the operating system recommends as the main +/// screen. On macOS, this is the screen that is currently receiving +/// keyboard input. This screen is defined by the operating system and +/// not chosen by Ghostty. +/// +/// * `mouse` - The screen that the mouse is currently hovered over. +/// +/// * `macos-menu-bar` - The screen that contains the macOS menu bar as +/// set in the display settings on macOS. This is a bit confusing because +/// every screen on macOS has a menu bar, but this is the screen that +/// contains the primary menu bar. +/// +/// The default value is `main` because this is the recommended screen +/// by the operating system. +@"quick-terminal-screen": QuickTerminalScreen = .main, + /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: /// @@ -4423,6 +4443,13 @@ pub const QuickTerminalPosition = enum { right, }; +/// See quick-terminal-screen +pub const QuickTerminalScreen = enum { + main, + mouse, + @"macos-menu-bar", +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy,