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.