mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-04-25 19:08:39 +03:00
548 lines
22 KiB
Swift
548 lines
22 KiB
Swift
import Foundation
|
|
import Cocoa
|
|
import SwiftUI
|
|
import GhosttyKit
|
|
|
|
/// A classic, tabbed terminal experience.
|
|
class TerminalController: BaseTerminalController
|
|
{
|
|
override var windowNibName: NSNib.Name? { "Terminal" }
|
|
|
|
/// Fullscreen state management.
|
|
let fullscreenHandler = FullScreenHandler()
|
|
|
|
/// 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
|
|
|
|
/// This is set to false by init if the window managed by this controller should not be restorable.
|
|
/// For example, terminals executing custom scripts are not restorable.
|
|
private var restorable: Bool = true
|
|
|
|
init(_ ghostty: Ghostty.App,
|
|
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
|
withSurfaceTree tree: Ghostty.SplitNode? = nil
|
|
) {
|
|
// 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
|
|
// as the script. We may want to revisit this behavior when we have scrollback
|
|
// restoration.
|
|
self.restorable = (base?.command ?? "") == ""
|
|
|
|
super.init(ghostty, baseConfig: base, surfaceTree: tree)
|
|
|
|
// 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(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: 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() {
|
|
guard let window = window as? TerminalWindow else { return }
|
|
window.focusFollowsMouse = ghostty.config.focusFollowsMouse
|
|
syncAppearance()
|
|
}
|
|
|
|
/// 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, when a window is closed, and when tabs are reordered
|
|
/// with the mouse.
|
|
func relabelTabs() {
|
|
// Reset this to false. It'll be set back to true later.
|
|
tabListenForFrame = false
|
|
|
|
guard let windows = self.window?.tabbedWindows as? [TerminalWindow] 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 (tab, window) in zip(1..., windows) {
|
|
// We need to clear any windows beyond this because they have had
|
|
// a keyEquivalent set previously.
|
|
guard tab <= 9 else {
|
|
window.keyEquivalent = ""
|
|
continue
|
|
}
|
|
|
|
let action = "goto_tab:\(tab)"
|
|
if let equiv = ghostty.config.keyEquivalent(for: action) {
|
|
window.keyEquivalent = "\(equiv)"
|
|
} else {
|
|
window.keyEquivalent = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fixTabBar() {
|
|
// We do this to make sure that the tab bar will always re-composite. If we don't,
|
|
// then the it will "drag" pieces of the background with it when a transparent
|
|
// window is moved around.
|
|
//
|
|
// There might be a better way to make the tab bar "un-lazy", but I can't find it.
|
|
if let window = window, !window.isOpaque {
|
|
window.isOpaque = true
|
|
window.isOpaque = false
|
|
}
|
|
}
|
|
|
|
@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()
|
|
}
|
|
|
|
private func syncAppearance() {
|
|
guard let window = self.window as? TerminalWindow else { return }
|
|
|
|
// If our window is not visible, then delay this. This is possible specifically
|
|
// during state restoration but probably in other scenarios as well. To delay,
|
|
// we just loop directly on the dispatch queue. We have to delay because some
|
|
// APIs such as window blur have no effect unless the window is visible.
|
|
guard window.isVisible else {
|
|
// Weak window so that if the window changes or is destroyed we aren't holding a ref
|
|
DispatchQueue.main.async { [weak self] in self?.syncAppearance() }
|
|
return
|
|
}
|
|
|
|
// Set the font for the window and tab titles.
|
|
if let titleFontName = ghostty.config.windowTitleFontFamily {
|
|
window.titlebarFont = NSFont(name: titleFontName, size: NSFont.systemFontSize)
|
|
} else {
|
|
window.titlebarFont = nil
|
|
}
|
|
|
|
// If we have window transparency then set it transparent. Otherwise set it opaque.
|
|
if (ghostty.config.backgroundOpacity < 1) {
|
|
window.isOpaque = false
|
|
|
|
// This is weird, but we don't use ".clear" because this creates a look that
|
|
// matches Terminal.app much more closer. This lets users transition from
|
|
// Terminal.app more easily.
|
|
window.backgroundColor = .white.withAlphaComponent(0.001)
|
|
|
|
ghostty_set_window_background_blur(ghostty.app, Unmanaged.passUnretained(window).toOpaque())
|
|
} else {
|
|
window.isOpaque = true
|
|
window.backgroundColor = .windowBackgroundColor
|
|
}
|
|
|
|
window.hasShadow = ghostty.config.macosWindowShadow
|
|
|
|
guard window.hasStyledTabs else { return }
|
|
|
|
// The titlebar is always updated. We don't need to worry about opacity
|
|
// because we handle it here.
|
|
let backgroundColor = OSColor(ghostty.config.backgroundColor)
|
|
window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity)
|
|
|
|
if (window.isOpaque) {
|
|
// Bg color is only synced if we have no transparency. This is because
|
|
// the transparency is handled at the surface level (window.backgroundColor
|
|
// ignores alpha components)
|
|
window.backgroundColor = backgroundColor
|
|
|
|
// If there is transparency, calling this will make the titlebar opaque
|
|
// so we only call this if we are opaque.
|
|
window.updateTabBar()
|
|
}
|
|
}
|
|
|
|
//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 as? TerminalWindow else { return }
|
|
|
|
// Setting all three of these is required for restoration to work.
|
|
window.isRestorable = restorable
|
|
if (restorable) {
|
|
window.restorationClass = TerminalWindowRestoration.self
|
|
window.identifier = .init(String(describing: TerminalWindowRestoration.self))
|
|
}
|
|
|
|
// If window decorations are disabled, remove our title
|
|
if (!ghostty.config.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 this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
|
|
// Ghostty defaults to sRGB but this can be overridden.
|
|
switch (ghostty.config.windowColorspace) {
|
|
case "display-p3":
|
|
window.colorSpace = .displayP3
|
|
case "srgb":
|
|
fallthrough
|
|
default:
|
|
window.colorSpace = .sRGB
|
|
}
|
|
|
|
// If we have only a single surface (no splits) and that surface requested
|
|
// an initial size then we set it here now.
|
|
if case let .leaf(leaf) = surfaceTree {
|
|
if let initialSize = leaf.surface.initialSize,
|
|
let screen = window.screen ?? NSScreen.main {
|
|
// Setup our frame. We need to first subtract the views frame so that we can
|
|
// just get the chrome frame so that we only affect the surface view size.
|
|
var frame = window.frame
|
|
frame.size.width -= leaf.surface.frame.size.width
|
|
frame.size.height -= leaf.surface.frame.size.height
|
|
frame.size.width += min(initialSize.width, screen.frame.width)
|
|
frame.size.height += min(initialSize.height, screen.frame.height)
|
|
|
|
// We have no tabs and we are not a split, so set the initial size of the window.
|
|
window.setFrame(frame, display: true)
|
|
}
|
|
}
|
|
|
|
// Center the window to start, we'll move the window frame automatically
|
|
// when cascading.
|
|
window.center()
|
|
|
|
// Make sure our theme is set on the window so styling is correct.
|
|
if let windowTheme = ghostty.config.windowTheme {
|
|
window.windowTheme = .init(rawValue: windowTheme)
|
|
}
|
|
|
|
// Handle titlebar tabs config option. Something about what we do while setting up the
|
|
// titlebar tabs interferes with the window restore process unless window.tabbingMode
|
|
// is set to .preferred, so we set it, and switch back to automatic as soon as we can.
|
|
if (ghostty.config.macosTitlebarStyle == "tabs") {
|
|
window.tabbingMode = .preferred
|
|
window.titlebarTabs = true
|
|
DispatchQueue.main.async {
|
|
window.tabbingMode = .automatic
|
|
}
|
|
} else if (ghostty.config.macosTitlebarStyle == "transparent") {
|
|
window.transparentTabs = true
|
|
}
|
|
|
|
if window.hasStyledTabs {
|
|
// Set the background color of the window
|
|
let backgroundColor = NSColor(ghostty.config.backgroundColor)
|
|
window.backgroundColor = backgroundColor
|
|
|
|
// This makes sure our titlebar renders correctly when there is a transparent background
|
|
window.titlebarColor = backgroundColor.withAlphaComponent(ghostty.config.backgroundOpacity)
|
|
}
|
|
|
|
// Initialize our content view to the SwiftUI root
|
|
window.contentView = NSHostingView(rootView: TerminalView(
|
|
ghostty: self.ghostty,
|
|
viewModel: self,
|
|
delegate: self
|
|
))
|
|
|
|
// If our titlebar style is "hidden" we adjust the style appropriately
|
|
if (ghostty.config.macosTitlebarStyle == "hidden") {
|
|
window.styleMask = [
|
|
// We need `titled` in the mask to get the normal window frame
|
|
.titled,
|
|
|
|
// Full size content view so we can extend
|
|
// content in to the hidden titlebar's area
|
|
.fullSizeContentView,
|
|
|
|
.resizable,
|
|
.closable,
|
|
.miniaturizable,
|
|
]
|
|
|
|
// Hide the title
|
|
window.titleVisibility = .hidden
|
|
window.titlebarAppearsTransparent = true
|
|
|
|
// Hide the traffic lights (window control buttons)
|
|
window.standardWindowButton(.closeButton)?.isHidden = true
|
|
window.standardWindowButton(.miniaturizeButton)?.isHidden = true
|
|
window.standardWindowButton(.zoomButton)?.isHidden = true
|
|
|
|
// Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar.
|
|
window.tabbingMode = .disallowed
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
window.focusFollowsMouse = ghostty.config.focusFollowsMouse
|
|
|
|
// Apply any additional appearance-related properties to the new window.
|
|
syncAppearance()
|
|
}
|
|
|
|
// 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
|
|
|
|
override func windowWillClose(_ notification: Notification) {
|
|
super.windowWillClose(notification)
|
|
self.relabelTabs()
|
|
}
|
|
|
|
override func windowDidBecomeKey(_ notification: Notification) {
|
|
super.windowDidBecomeKey(notification)
|
|
self.relabelTabs()
|
|
self.fixTabBar()
|
|
}
|
|
|
|
func windowDidMove(_ notification: Notification) {
|
|
self.fixTabBar()
|
|
}
|
|
|
|
// Called when the window will be encoded. We handle the data encoding here in the
|
|
// window controller.
|
|
func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) {
|
|
let data = TerminalRestorableState(from: self)
|
|
data.encode(with: state)
|
|
}
|
|
|
|
// 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 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.
|
|
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 toggleGhosttyFullScreen(_ sender: Any) {
|
|
guard let surface = focusedSurface?.surface else { return }
|
|
ghostty.toggleFullscreen(surface: surface)
|
|
}
|
|
|
|
@IBAction func toggleTerminalInspector(_ sender: Any) {
|
|
guard let surface = focusedSurface?.surface else { return }
|
|
ghostty.toggleTerminalInspector(surface: surface)
|
|
}
|
|
|
|
//MARK: - TerminalViewDelegate
|
|
|
|
override func titleDidChange(to: String) {
|
|
super.titleDidChange(to: to)
|
|
|
|
guard let window = window as? TerminalWindow else { return }
|
|
|
|
// Custom toolbar-based title used when titlebar tabs are enabled.
|
|
if let toolbar = window.toolbar as? TerminalToolbar {
|
|
if (window.titlebarTabs || ghostty.config.macosTitlebarStyle == "hidden") {
|
|
// Updating the title text as above automatically reveals the
|
|
// native title view in macOS 15.0 and above. Since we're using
|
|
// a custom view instead, we need to re-hide it.
|
|
window.titleVisibility = .hidden
|
|
}
|
|
toolbar.titleText = to
|
|
}
|
|
}
|
|
|
|
override func surfaceTreeDidChange() {
|
|
// Whenever our surface tree changes in any way (new split, close split, etc.)
|
|
// we want to invalidate our state.
|
|
invalidateRestorableState()
|
|
}
|
|
|
|
override func zoomStateDidChange(to: Bool) {
|
|
guard let window = window as? TerminalWindow else { return }
|
|
window.surfaceIsZoomed = to
|
|
}
|
|
|
|
//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 tabEnumAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return }
|
|
guard let tabEnum = tabEnumAny as? ghostty_action_goto_tab_e else { return }
|
|
let tabIndex: Int32 = tabEnum.rawValue
|
|
|
|
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_GOTO_TAB_PREVIOUS.rawValue) {
|
|
if (selectedIndex == 0) {
|
|
finalIndex = tabbedWindows.count - 1
|
|
} else {
|
|
finalIndex = selectedIndex - 1
|
|
}
|
|
} else if (tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue) {
|
|
if (selectedIndex == tabbedWindows.count - 1) {
|
|
finalIndex = 0
|
|
} else {
|
|
finalIndex = selectedIndex + 1
|
|
}
|
|
} else if (tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue) {
|
|
finalIndex = tabbedWindows.count - 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 fullscreenModeAny = notification.userInfo?[Ghostty.Notification.FullscreenModeKey] else { return }
|
|
guard let fullscreenMode = fullscreenModeAny as? ghostty_action_fullscreen_e else { return }
|
|
self.fullscreenHandler.toggleFullscreen(window: window, mode: fullscreenMode)
|
|
|
|
// For some reason focus always gets lost when we toggle fullscreen, so we set it back.
|
|
if let focusedSurface {
|
|
Ghostty.moveFocus(to: focusedSurface)
|
|
}
|
|
}
|
|
}
|