diff --git a/include/ghostty.h b/include/ghostty.h index b5dd1609b..e66ce08ea 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_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 9bff35757..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 */; }; @@ -34,6 +35,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,6 +63,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 /* 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 */; }; @@ -98,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 = ""; }; @@ -105,6 +112,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 = ""; }; @@ -132,6 +140,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 /* 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 = ""; }; @@ -204,6 +216,7 @@ A5CBD0672CA2704E0017A1AE /* Global Keybinds */, A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, + A5CBD05A2CA0C5910017A1AE /* QuickTerminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, A534263E2A7DCC5800EBB7A2 /* Settings */, @@ -333,6 +346,7 @@ A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */, AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, + A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */, ); path = Terminal; sourceTree = ""; @@ -373,6 +387,18 @@ name = Products; sourceTree = ""; }; + A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = { + isa = PBXGroup; + children = ( + A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, + A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, + A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, + A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */, + A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */, + ); + path = QuickTerminal; + sourceTree = ""; + }; A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { isa = PBXGroup; children = ( @@ -506,6 +532,7 @@ A5985CE62C33060F00C57AD3 /* man in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, 552964E62B34A9B400030505 /* vim in Resources */, + A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -526,13 +553,17 @@ 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 */, C1F26EA72B738B9900404083 /* NSView+Extension.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 /* 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 */, @@ -560,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/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index fc24345a8..5980b8d66 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 menuQuickTerminal: 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 quick terminal. This starts out uninitialized and only initializes if used. + private var quickController: QuickTerminalController? = nil + /// Manages updates let updaterController: SPUStandardUpdaterController let updaterDelegate: UpdaterDelegate = UpdaterDelegate() @@ -310,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_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput) @@ -545,4 +550,18 @@ class AppDelegate: NSObject, @IBAction func toggleSecureInput(_ sender: Any) { setSecureInput(.toggle) } + + @IBAction func toggleQuickTerminal(_ sender: Any) { + if quickController == nil { + quickController = QuickTerminalController( + ghostty, + position: ghostty.config.quickTerminalPosition + ) + } + + guard let quickController = self.quickController else { return } + quickController.toggle() + + self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off } + } } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index beb411987..63aae4c60 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -32,6 +32,7 @@ + @@ -216,6 +217,13 @@ + + + + + + + diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminal.xib b/macos/Sources/Features/QuickTerminal/QuickTerminal.xib new file mode 100644 index 000000000..b2a99cbf5 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminal.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift new file mode 100644 index 000000000..f5d899e76 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -0,0 +1,211 @@ +import Foundation +import Cocoa +import SwiftUI +import GhosttyKit + +/// Controller for the "quick" terminal. +class QuickTerminalController: BaseTerminalController { + override var windowNibName: NSNib.Name? { "QuickTerminal" } + + /// 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, + surfaceTree tree: Ghostty.SplitNode? = nil + ) { + self.position = position + super.init(ghostty, baseConfig: base, surfaceTree: tree) + } + + 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 } + + // The controller is the window delegate so we can detect events such as + // window close so we can animate out. + window.delegate = self + + // 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 + + // Setup our initial size based on our configured position + position.setLoaded(window) + + // Setup our content + window.contentView = NSHostingView(rootView: TerminalView( + ghostty: self.ghostty, + viewModel: self, + delegate: self + )) + + // Animate the window in + animateIn() + } + + // MARK: NSWindowDelegate + + 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() + } + + func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { + // 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) + } + + // MARK: Base Controller Overrides + + override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + super.surfaceTreeDidChange(from: from, to: to) + + // If our surface tree is nil then we animate the window out. + if (to == nil) { + animateOut() + } + } + + // MARK: Methods + + func toggle() { + if (visible) { + animateOut() + } else { + animateIn() + } + } + + 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) + + // 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 + // animate out. + if (surfaceTree == 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) + } + } + } + + func animateOut() { + guard let window = self.window else { return } + + // Set our visibility state + guard visible else { return } + visible = false + + animateWindowOut(window: window, to: position) + } + + private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { + guard let screen = ghostty.config.quickTerminalScreen.screen else { return } + + // Move our window off screen to the top + position.setInitial(in: window, on: screen) + + // 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.2 + context.timingFunction = .init(name: .easeIn) + position.setFinal(in: window.animator(), on: screen) + } + } + + private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { + // 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 + // in the app. + let wasKey = window.isKeyWindow + + NSAnimationContext.runAnimationGroup({ context in + context.duration = 0.2 + 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) + } + } + + // MARK: First Responder + + @IBAction override func closeWindow(_ sender: Any) { + // 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) + } +} diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift new file mode 100644 index 000000000..51b450700 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -0,0 +1,102 @@ +import Cocoa + +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, .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) + } + } + + /// 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 + window.setFrame(.init( + origin: initialOrigin(for: window, on: screen), + size: restrictFrameSize(window.frame.size, on: screen) + ), 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 + window.setFrame(.init( + origin: finalOrigin(for: window, on: screen), + size: restrictFrameSize(window.frame.size, on: screen) + ), display: true) + } + + /// Restrict the frame size during resizing. + func restrictFrameSize(_ size: NSSize, on screen: NSScreen) -> NSSize { + var finalSize = size + switch (self) { + case .top, .bottom: + finalSize.width = screen.frame.width + + case .left, .right: + finalSize.height = screen.frame.height + } + + return finalSize + } + + /// The initial point origin for this position. + func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { + switch (self) { + case .top: + return .init(x: screen.frame.minX, y: screen.frame.maxY) + + case .bottom: + return .init(x: screen.frame.minX, y: -window.frame.height) + + case .left: + return .init(x: -window.frame.width, y: 0) + + case .right: + return .init(x: screen.frame.maxX, y: 0) + } + } + + /// The final point origin for this position. + func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { + switch (self) { + case .top: + return .init(x: screen.frame.minX, y: screen.visibleFrame.maxY - window.frame.height) + + case .bottom: + return .init(x: screen.frame.minX, y: screen.frame.minY) + + case .left: + 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/Features/QuickTerminal/QuickTerminalWindow.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift new file mode 100644 index 000000000..2d9d1df7c --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift @@ -0,0 +1,39 @@ +import Cocoa + +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 } + 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 + + // 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, + + // We don't want to be part of command-tilde + .ignoresCycle, + + // We never support fullscreen + .fullScreenNone] + } +} diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift new file mode 100644 index 000000000..4417ce9cc --- /dev/null +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -0,0 +1,363 @@ +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) { + 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. + 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 248c09056..ec7d7c229 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -18,17 +18,10 @@ protocol TerminalViewDelegate: AnyObject { /// not called initially. func surfaceTreeDidChange() + /// This is called when a split is zoomed. 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. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 5b2efad3e..05c01a75e 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_QUICK_TERMINAL: + toggleQuickTerminal(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 toggleQuickTerminal( + _ app: ghostty_app_t, + target: ghostty_target_s + ) { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + appDelegate.toggleQuickTerminal(self) + } + private static func setTitle( _ app: ghostty_app_t, target: ghostty_target_s, diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 7ecd45cc4..76f85d2a3 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -332,6 +332,28 @@ extension Ghostty { return Color(newColor) } + #if canImport(AppKit) + 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 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 { guard let config = self.config else { return .after_first } var v: UnsafePointer? = nil diff --git a/src/App.zig b/src/App.zig index 2e8ac3cf6..5922528ab 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_quick_terminal => try rt_app.performAction(.app, .toggle_quick_terminal, {}), } } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 9ed89b5a3..2f7616bc4 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 quick terminal in or out. + toggle_quick_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_quick_terminal, goto_tab, goto_split, resize_split, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index fb31f7c2b..87314c0e1 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_quick_terminal, .goto_tab, .inspector, .render_inspector, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 94fae8015..9bbfad94e 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_quick_terminal, .size_limit, .cell_size, .secure_input, diff --git a/src/config/Config.zig b/src/config/Config.zig index efa741307..0f5e9b81b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1220,6 +1220,40 @@ 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. +/// +/// 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, + +/// 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: /// @@ -4401,6 +4435,21 @@ pub const ResizeOverlayPosition = enum { @"bottom-right", }; +/// See quick-terminal-position +pub const QuickTerminalPosition = enum { + top, + bottom, + left, + right, +}; + +/// See quick-terminal-screen +pub const QuickTerminalScreen = enum { + main, + mouse, + @"macos-menu-bar", +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f9921a87e..5df3ae8e4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -363,6 +363,27 @@ pub const Action = union(enum) { /// This only works on macOS, since this is a system API on macOS. toggle_secure_input: void, + /// 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. + /// + /// 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. + /// + /// 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). + /// + /// See the various configurations for the quick terminal in the + /// configuration file to customize its behavior. + toggle_quick_terminal: void, + /// Quit ghostty. quit: void, @@ -563,6 +584,7 @@ pub const Action = union(enum) { .reload_config, .close_all_windows, .quit, + .toggle_quick_terminal, => .app, // These are app but can be special-cased in a surface context.