diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a525abd94..d6d85d1ac 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ A596309A2AEE1C6400D64628 /* Terminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A59630992AEE1C6400D64628 /* Terminal.xib */; }; A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309B2AEE1C9E00D64628 /* TerminalController.swift */; }; A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309D2AEE1D6C00D64628 /* TerminalView.swift */; }; + A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A596309F2AEF6AEB00D64628 /* TerminalManager.swift */; }; A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */; }; A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; @@ -68,6 +69,7 @@ A59630992AEE1C6400D64628 /* Terminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = Terminal.xib; sourceTree = ""; }; A596309B2AEE1C9E00D64628 /* TerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalController.swift; sourceTree = ""; }; A596309D2AEE1D6C00D64628 /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; + A596309F2AEF6AEB00D64628 /* TerminalManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalManager.swift; sourceTree = ""; }; A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InspectorView.swift; sourceTree = ""; }; A59FB5D02AE0DEA7009128F3 /* MetalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetalView.swift; sourceTree = ""; }; A5A1F8842A489D6800D1E8BC /* terminfo */ = {isa = PBXFileReference; lastKnownFileType = folder; name = terminfo; path = "../zig-out/share/terminfo"; sourceTree = ""; }; @@ -184,6 +186,7 @@ isa = PBXGroup; children = ( A59630992AEE1C6400D64628 /* Terminal.xib */, + A596309F2AEF6AEB00D64628 /* TerminalManager.swift */, A596309B2AEE1C9E00D64628 /* TerminalController.swift */, A596309D2AEE1D6C00D64628 /* TerminalView.swift */, ); @@ -319,6 +322,7 @@ A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, + A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 9c3afac13..26afa3f21 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -42,16 +42,16 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp private var dockMenu: NSMenu = NSMenu() /// The ghostty global state. Only one per process. - private var ghostty: Ghostty.AppState = Ghostty.AppState() + private let ghostty: Ghostty.AppState = Ghostty.AppState() - /// Manages windows and tabs, ensuring they're allocated/deallocated correctly - var windowManager: PrimaryWindowManager! + /// Manages our terminal windows. + let terminalManager: TerminalManager override init() { + self.terminalManager = TerminalManager(ghostty) super.init() ghostty.delegate = self - windowManager = PrimaryWindowManager(ghostty: self.ghostty) } //MARK: - NSApplicationDelegate @@ -73,15 +73,11 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp // Let's launch our first window. // TODO: we should detect if we restored windows and if so not launch a new window. - // TODO: remove when TerminalController is done - // windowManager.addInitialWindow() + terminalManager.newWindow() // Initial config loading configDidReload(ghostty) - let c = TerminalController(ghostty) - c.showWindow(self) - // Register our service provider. This must happen after everything // else is initialized. NSApp.servicesProvider = ServiceProvider() @@ -151,7 +147,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp guard !flag else { return true } // No visible windows, open a new one. - windowManager.newWindow() + terminalManager.newWindow() return false } @@ -174,16 +170,9 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp // Build our config var config = Ghostty.SurfaceConfiguration() config.workingDirectory = filename - - // If we don't have a window open through the window manager, we launch - // a new window. - guard let mainWindow = windowManager.mainWindow else { - windowManager.addNewWindow(withBaseConfig: config) - return true - } - // Add a new tab - windowManager.addNewTab(to: mainWindow, withBaseConfig: config) + // Add a new tab or create a new window + terminalManager.newTab(withBaseConfig: config) return true } @@ -258,7 +247,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp func configDidReload(_ state: Ghostty.AppState) { // Config could change keybindings, so update everything that depends on that syncMenuShortcuts() - windowManager.relabelTabs() + //windowManager.relabelTabs() // Config could change window appearance syncAppearance() @@ -308,7 +297,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp } @IBAction func newWindow(_ sender: Any?) { - windowManager.newWindow() + terminalManager.newWindow() // We also activate our app so that it becomes front. This may be // necessary for the dock menu. @@ -316,7 +305,7 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp } @IBAction func newTab(_ sender: Any?) { - windowManager.newTab() + terminalManager.newTab() // We also activate our app so that it becomes front. This may be // necessary for the dock menu. diff --git a/macos/Sources/Features/Services/ServiceProvider.swift b/macos/Sources/Features/Services/ServiceProvider.swift index a95dd4923..74ca6d9bc 100644 --- a/macos/Sources/Features/Services/ServiceProvider.swift +++ b/macos/Sources/Features/Services/ServiceProvider.swift @@ -39,7 +39,7 @@ class ServiceProvider: NSObject { private func openTerminal(_ path: String, target: OpenTarget) { guard let delegateRaw = NSApp.delegate else { return } guard let delegate = delegateRaw as? AppDelegate else { return } - guard let windowManager = delegate.windowManager else { return } + let terminalManager = delegate.terminalManager // We only open in directories. var isDirectory = ObjCBool(true) @@ -49,20 +49,13 @@ class ServiceProvider: NSObject { // Build our config var config = Ghostty.SurfaceConfiguration() config.workingDirectory = path - - // If we don't have a window open through the window manager, we launch - // a new window even if they requested a tab. - guard let mainWindow = windowManager.mainWindow else { - windowManager.addNewWindow(withBaseConfig: config) - return - } switch (target) { case .window: - windowManager.addNewWindow(withBaseConfig: config) + terminalManager.newWindow(withBaseConfig: config) case .tab: - windowManager.addNewTab(to: mainWindow, withBaseConfig: config) + terminalManager.newTab(withBaseConfig: config) } } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 6fb3c6af6..d8a0c0d64 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -3,7 +3,7 @@ import Cocoa import SwiftUI import Combine -class TerminalController: NSWindowController, NSWindowDelegate { +class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate { override var windowNibName: NSNib.Name? { "Terminal" } /// The app instance that this terminal view will represent. @@ -12,6 +12,11 @@ class TerminalController: NSWindowController, NSWindowDelegate { init(_ ghostty: Ghostty.AppState) { self.ghostty = ghostty super.init(window: nil) + + // Register as observer for window-level manipulations that are best handled + // here at the controller layer rather than in the SwiftUI stack. + let center = NotificationCenter.default + } required init?(coder: NSCoder) { @@ -39,7 +44,8 @@ class TerminalController: NSWindowController, NSWindowDelegate { // Initialize our content view to the SwiftUI root window.contentView = NSHostingView(rootView: TerminalView( - ghostty: self.ghostty + ghostty: self.ghostty, + delegate: self )) } @@ -47,4 +53,15 @@ class TerminalController: NSWindowController, NSWindowDelegate { func windowWillClose(_ notification: Notification) { } + + //MARK: - TerminalViewDelegate + + func titleDidChange(to: String) { + self.window?.title = to + } + + func cellSizeDidChange(to: NSSize) { + guard ghostty.windowStepResize else { return } + self.window?.contentResizeIncrements = to + } } diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift new file mode 100644 index 000000000..35eb054bb --- /dev/null +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -0,0 +1,62 @@ +import Cocoa + +/// Manages a set of terminal windows. +class TerminalManager { + struct Window { + let controller: TerminalController + } + + let ghostty: Ghostty.AppState + + /// The set of windows we currently have. + private var windows: [Window] = [] + + /// Returns the main window of the managed window stack. If there is no window + /// then an arbitrary window will be chosen. + private var mainWindow: Window? { + for window in windows { + if (window.controller.window?.isMainWindow ?? false) { + return window + } + } + + // If we have no main window, just use the first window. + return windows.first + } + + init(_ ghostty: Ghostty.AppState) { + self.ghostty = ghostty + } + + /// Create a new terminal window. + func newWindow(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { + let c = createWindow(withBaseConfig: base) + c.showWindow(self) + } + + /// Creates a new tab in the current main window. If there are no windows, a window + /// is created. + func newTab(withBaseConfig base: Ghostty.SurfaceConfiguration? = nil) { + // If there is no main window, just create a new window + guard let parent = mainWindow?.controller.window else { + newWindow(withBaseConfig: base) + return + } + + // Create a new window and add it to the parent + let window = createWindow(withBaseConfig: base).window! + parent.addTabbedWindow(window, ordered: .above) + window.makeKeyAndOrderFront(self) + } + + /// Creates a window controller, adds it to our managed list, and returns it. + func createWindow(withBaseConfig: Ghostty.SurfaceConfiguration?) -> TerminalController { + // Initialize our controller to load the window + let c = TerminalController(ghostty) + + // Keep track of every window we manage + windows.append(Window(controller: c)) + + return c + } +} diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 1edbb3ef9..e10bf0b29 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -1,12 +1,33 @@ import SwiftUI import GhosttyKit +protocol TerminalViewDelegate: AnyObject { + /// Called when the currently focused surface changed. This can be nil. + func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) + + /// The title of the terminal should change. + func titleDidChange(to: String) + + /// The cell size changed. + func cellSizeDidChange(to: NSSize) +} + +extension TerminalViewDelegate { + func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {} + func titleDidChange(to: String) {} + func cellSizeDidChange(to: NSSize) {} +} + struct TerminalView: View { @ObservedObject var ghostty: Ghostty.AppState + // An optional delegate to receive information about terminal changes. + weak var delegate: TerminalViewDelegate? = nil + // This seems like a crutch after switching from SwiftUI to AppKit lifecycle. @FocusState private var focused: Bool + // Various state values sent back up from the currently focused terminals. @FocusedValue(\.ghosttySurfaceView) private var focusedSurface @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle @FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit @@ -38,10 +59,6 @@ struct TerminalView: View { case .error: ErrorView() case .ready: - let center = NotificationCenter.default - let gotoTab = center.publisher(for: Ghostty.Notification.ghosttyGotoTab) - let toggleFullscreen = center.publisher(for: Ghostty.Notification.ghosttyToggleFullscreen) - VStack(spacing: 0) { // If we're running in debug mode we show a warning so that users // know that performance will be degraded. @@ -54,6 +71,16 @@ struct TerminalView: View { .ghosttyConfig(ghostty.config!) .focused($focused) .onAppear { self.focused = true } + .onChange(of: focusedSurface) { newValue in + self.delegate?.focusedSurfaceDidChange(to: newValue) + } + .onChange(of: title) { newValue in + self.delegate?.titleDidChange(to: newValue) + } + .onChange(of: cellSize) { newValue in + guard let size = newValue else { return } + self.delegate?.cellSizeDidChange(to: size) + } } } }