ghostty/macos/Sources/Features/Terminal/TerminalController.swift
2023-12-22 21:24:27 -08:00

578 lines
22 KiB
Swift

import Foundation
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
{
override var windowNibName: NSNib.Name? { "Terminal" }
/// The app instance that this terminal view will represent.
let ghostty: Ghostty.AppState
/// The currently focused surface.
var focusedSurface: Ghostty.SurfaceView? = nil
/// 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()
lastSurfaceDidClose()
}
}
}
/// Fullscreen state management.
private 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.
private var tabListenForFrame: Bool = false
/// This is the hash value of the last tabGroup.windows array. We use this to detect order
/// changes in the list.
private var tabWindowsHash: Int = 0
init(_ ghostty: Ghostty.AppState, withBaseConfig base: Ghostty.SurfaceConfiguration? = 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 = .leaf(.init(ghostty_app, base))
// Setup our notifications for behaviors
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(onToggleFullscreen),
name: Ghostty.Notification.ghosttyToggleFullscreen,
object: nil)
center.addObserver(
self,
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),
name: NSView.frameDidChangeNotification,
object: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
deinit {
// Remove all of our notificationcenter subscriptions
let center = NotificationCenter.default
center.removeObserver(self)
}
//MARK: - Methods
/// Update the accessory view of each tab according to the keyboard
/// shortcut that activates it (if any). This is called when the key window
/// changes and when a window is closed.
func relabelTabs() {
// Reset this to false. It'll be set back to true later.
tabListenForFrame = false
guard let windows = self.window?.tabbedWindows else { return }
guard let cfg = ghostty.config else { return }
// We only listen for frame changes if we have more than 1 window,
// otherwise the accessory view doesn't matter.
tabListenForFrame = windows.count > 1
for (index, window) in windows.enumerated().prefix(9) {
let action = "goto_tab:\(index + 1)"
let trigger = ghostty_config_trigger(cfg, action, UInt(action.count))
guard let equiv = Ghostty.keyEquivalentLabel(key: trigger.key, mods: trigger.mods) else {
continue
}
let attributes: [NSAttributedString.Key: Any] = [
.font: NSFont.labelFont(ofSize: 0),
.foregroundColor: window.isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
]
let attributedString = NSAttributedString(string: " \(equiv) ", attributes: attributes)
let text = NSTextField(labelWithAttributedString: attributedString)
text.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal)
text.postsFrameChangedNotifications = true
window.tab.accessoryView = text
}
}
@objc private func onFrameDidChange(_ notification: NSNotification) {
// This is a huge hack to set the proper shortcut for tab selection
// on tab reordering using the mouse. There is no event, delegate, etc.
// as far as I can tell for when a tab is manually reordered with the
// mouse in a macOS-native tab group, so the way we detect it is setting
// the accessoryView "postsFrameChangedNotification" to true, listening
// for the view frame to change, comparing the windows list, and
// relabeling the tabs.
guard tabListenForFrame else { return }
guard let v = self.window?.tabbedWindows?.hashValue else { return }
guard tabWindowsHash != v else { return }
tabWindowsHash = v
self.relabelTabs()
}
//MARK: - NSWindowController
override func windowWillLoad() {
// We do NOT want to cascade because we handle this manually from the manager.
shouldCascadeWindows = false
}
override func windowDidLoad() {
guard let window = window else { return }
// Setting all three of these is required for restoration to work.
window.isRestorable = true
window.restorationClass = TerminalWindowRestoration.self
window.identifier = .init(String(describing: TerminalWindowRestoration.self))
// If window decorations are disabled, remove our title
if (!ghostty.windowDecorations) { window.styleMask.remove(.titled) }
// Terminals typically operate in sRGB color space and macOS defaults
// to "native" which is typically P3. There is a lot more resources
// covered in thie GitHub issue: https://github.com/mitchellh/ghostty/pull/376
window.colorSpace = NSColorSpace.sRGB
// Center the window to start, we'll move the window frame automatically
// when cascading.
window.center()
// Initialize our content view to the SwiftUI root
window.contentView = NSHostingView(rootView: TerminalView(
ghostty: self.ghostty,
viewModel: self,
delegate: self
))
// In various situations, macOS automatically tabs new windows. Ghostty handles
// its own tabbing so we DONT want this behavior. This detects this scenario and undoes
// it.
//
// Example scenarios where this happens:
// - When the system user tabbing preference is "always"
// - When the "+" button in the tab bar is clicked
//
// We don't run this logic in fullscreen because in fullscreen this will end up
// removing the window and putting it into its own dedicated fullscreen, which is not
// the expected or desired behavior of anyone I've found.
if (!window.styleMask.contains(.fullScreen)) {
// If we have more than 1 window in our tab group we know we're a new window.
// Since Ghostty manages tabbing manually this will never be more than one
// at this point in the AppKit lifecycle (we add to the group after this).
if let tabGroup = window.tabGroup, tabGroup.windows.count > 1 {
window.tabGroup?.removeWindow(window)
}
}
}
// Shows the "+" button in the tab bar, responds to that click.
override func newWindowForTab(_ sender: Any?) {
// Trigger the ghostty core event logic for a new tab.
guard let surface = self.focusedSurface?.surface else { return }
ghostty.newTab(surface: surface)
}
//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
self.relabelTabs()
}
func windowDidBecomeKey(_ notification: Notification) {
self.relabelTabs()
}
func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) {
let data = TerminalRestorableState()
data.encode(with: state)
AppDelegate.logger.warning("state window del encode")
}
func window(_ window: NSWindow, didDecodeRestorableState state: NSCoder) {
AppDelegate.logger.warning("state window del restore")
}
//MARK: - First Responder
@IBAction func newWindow(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
ghostty.newWindow(surface: surface)
}
@IBAction func newTab(_ sender: Any?) {
guard let surface = focusedSurface?.surface else { return }
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) {
guard let window = window else { return }
guard let tabGroup = window.tabGroup else {
// No tabs, no tab group, just perform a normal close.
window.performClose(sender)
return
}
// If have one window then we just do a normal close
if tabGroup.windows.count == 1 {
window.performClose(sender)
return
}
// Check if any windows require close confirmation.
var needsConfirm: Bool = false
for tabWindow in tabGroup.windows {
guard let c = tabWindow.windowController as? TerminalController else { continue }
if (c.surfaceTree?.needsConfirmQuit() ?? false) {
needsConfirm = true
break
}
}
// If none need confirmation then we can just close all the windows.
if (!needsConfirm) {
for tabWindow in tabGroup.windows {
tabWindow.close()
}
return
}
// If we need confirmation by any, show one confirmation for all windows
// in the tab group.
let alert = NSAlert()
alert.messageText = "Close Window?"
alert.informativeText = "All terminal sessions in this window will be terminated."
alert.addButton(withTitle: "Close Window")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning
alert.beginSheetModal(for: window, completionHandler: { response in
if (response == .alertFirstButtonReturn) {
for tabWindow in tabGroup.windows {
tabWindow.close()
}
}
})
}
@IBAction func splitHorizontally(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT)
}
@IBAction func splitVertically(_ sender: Any) {
guard let surface = focusedSurface?.surface else { return }
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_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)
}
//MARK: - TerminalViewDelegate
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
self.focusedSurface = to
}
func titleDidChange(to: String) {
self.window?.title = to
}
func cellSizeDidChange(to: NSSize) {
guard ghostty.windowStepResize else { return }
self.window?.contentResizeIncrements = to
}
func lastSurfaceDidClose() {
self.window?.close()
}
//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.AppState.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true)
}
}
//MARK: - Notifications
@objc private func onGotoTab(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
guard let window = self.window else { return }
// Get the tab index from the notification
guard let tabIndexAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return }
guard let tabIndex = tabIndexAny as? Int32 else { return }
guard let windowController = window.windowController else { return }
guard let tabGroup = windowController.window?.tabGroup else { return }
let tabbedWindows = tabGroup.windows
// This will be the index we want to actual go to
let finalIndex: Int
// An index that is invalid is used to signal some special values.
if (tabIndex <= 0) {
guard let selectedWindow = tabGroup.selectedWindow else { return }
guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return }
if (tabIndex == GHOSTTY_TAB_PREVIOUS.rawValue) {
if (selectedIndex == 0) {
finalIndex = tabbedWindows.count - 1
} else {
finalIndex = selectedIndex - 1
}
} else if (tabIndex == GHOSTTY_TAB_NEXT.rawValue) {
if (selectedIndex == tabbedWindows.count - 1) {
finalIndex = 0
} else {
finalIndex = selectedIndex + 1
}
} else {
return
}
} else {
// Tabs are 0-indexed here, so we subtract one from the key the user hit.
finalIndex = Int(tabIndex - 1)
}
guard finalIndex >= 0 && finalIndex < tabbedWindows.count else { return }
let targetWindow = tabbedWindows[finalIndex]
targetWindow.makeKeyAndOrderFront(nil)
}
@objc private func onToggleFullscreen(notification: SwiftUI.Notification) {
guard let target = notification.object as? Ghostty.SurfaceView else { return }
guard target == self.focusedSurface else { return }
// We need a window to fullscreen
guard let window = self.window else { return }
// Check whether we use non-native fullscreen
guard let useNonNativeFullscreenAny = notification.userInfo?[Ghostty.Notification.NonNativeFullscreenKey] else { return }
guard let useNonNativeFullscreen = useNonNativeFullscreenAny as? ghostty_non_native_fullscreen_e else { return }
self.fullscreenHandler.toggleFullscreen(window: window, nonNativeFullscreen: useNonNativeFullscreen)
// For some reason focus always gets lost when we toggle fullscreen, so we set it back.
if let focusedSurface {
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.AppState.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!)
}
}