ghostty/macos/Sources/Features/Terminal/BaseTerminalController.swift
Mitchell Hashimoto 30e95e4b9a Revert "macos: setup colorspace in base terminal controller"
This reverts commit e64b231248f68b2fd1e19d538d243b886d5284ff.
2024-10-31 09:28:08 -07:00

567 lines
20 KiB
Swift

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.
///
/// Special considerations to implement:
///
/// - Fullscreen: you must manually listen for the right notification and implement the
/// callback that calls toggleFullscreen on this base class.
///
/// 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.
/// - 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,
FullscreenDelegate
{
/// 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
/// Fullscreen state management.
private(set) var fullscreenStyle: FullscreenStyle?
/// Event monitor (see individual events for why)
private var eventMonitor: Any? = nil
/// The previous frame information from the window
private var savedFrame: SavedFrame? = nil
struct SavedFrame {
let window: NSRect
let screen: NSRect
}
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)
center.addObserver(
self,
selector: #selector(didChangeScreenParametersNotification),
name: NSApplication.didChangeScreenParametersNotification,
object: nil)
// Listen for local events that we need to know of outside of
// single surface handlers.
self.eventMonitor = NSEvent.addLocalMonitorForEvents(
matching: [.flagsChanged],
handler: localEventHandler)
}
deinit {
NotificationCenter.default.removeObserver(self)
if let eventMonitor {
NSEvent.removeMonitor(eventMonitor)
}
}
/// 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)
}
}
// Call this whenever the frame changes
private func windowFrameDidChange() {
// We need to update our saved frame information in case of monitor
// changes (see didChangeScreenParameters notification).
savedFrame = nil
guard let window, let screen = window.screen else { return }
savedFrame = .init(window: window.frame, screen: screen.visibleFrame)
}
// MARK: Notifications
@objc private func didChangeScreenParametersNotification(_ notification: Notification) {
// If we have a window that is visible and it is outside the bounds of the
// screen then we clamp it back to within the screen.
guard let window else { return }
guard window.isVisible else { return }
// We ignore fullscreen windows because macOS automatically resizes
// those back to the fullscreen bounds.
guard !window.styleMask.contains(.fullScreen) else { return }
guard let screen = window.screen else { return }
let visibleFrame = screen.visibleFrame
var newFrame = window.frame
// Clamp width/height
if newFrame.size.width > visibleFrame.size.width {
newFrame.size.width = visibleFrame.size.width
}
if newFrame.size.height > visibleFrame.size.height {
newFrame.size.height = visibleFrame.size.height
}
// Ensure the window is on-screen. We only do this if the previous frame
// was also on screen. If a user explicitly wanted their window off screen
// then we let it stay that way.
x: if newFrame.origin.x < visibleFrame.origin.x {
if let savedFrame, savedFrame.window.origin.x < savedFrame.screen.origin.x {
break x;
}
newFrame.origin.x = visibleFrame.origin.x
}
y: if newFrame.origin.y < visibleFrame.origin.y {
if let savedFrame, savedFrame.window.origin.y < savedFrame.screen.origin.y {
break y;
}
newFrame.origin.y = visibleFrame.origin.y
}
// Apply the new window frame
window.setFrame(newFrame, display: true)
}
// MARK: Local Events
private func localEventHandler(_ event: NSEvent) -> NSEvent? {
return switch event.type {
case .flagsChanged:
localEventFlagsChanged(event)
default:
event
}
}
private func localEventFlagsChanged(_ event: NSEvent) -> NSEvent? {
// Go through all our surfaces and notify it that the flags changed.
if let surfaceTree {
var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0.surface }
// If we're the main window receiving key input, then we want to avoid
// calling this on our focused surface because that'll trigger a double
// flagsChanged call.
if NSApp.mainWindow == window {
surfaces = surfaces.filter { $0 != focusedSurface }
}
for surface in surfaces {
surface.flagsChanged(with: event)
}
}
return event
}
// 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 pwdDidChange(to: URL?) {
guard let window else { return }
if ghostty.config.macosTitlebarProxyIcon == .visible {
// Use the 'to' URL directly
window.representedURL = to
} else {
window.representedURL = nil
}
}
func cellSizeDidChange(to: NSSize) {
guard ghostty.config.windowStepResize else { return }
self.window?.contentResizeIncrements = to
}
func zoomStateDidChange(to: Bool) {}
// MARK: Fullscreen
/// Toggle fullscreen for the given mode.
func toggleFullscreen(mode: FullscreenMode) {
// We need a window to fullscreen
guard let window = self.window else { return }
// If we have a previous fullscreen style initialized, we want to check if
// our mode changed. If it changed and we're in fullscreen, we exit so we can
// toggle it next time. If it changed and we're not in fullscreen we can just
// switch the handler.
var newStyle = mode.style(for: window)
newStyle?.delegate = self
old: if let oldStyle = self.fullscreenStyle {
// If we're not fullscreen, we can nil it out so we get the new style
if !oldStyle.isFullscreen {
self.fullscreenStyle = newStyle
break old
}
assert(oldStyle.isFullscreen)
// We consider our mode changed if the types change (obvious) but
// also if its nil (not obvious) because nil means that the style has
// likely changed but we don't support it.
if newStyle == nil || type(of: newStyle) != type(of: oldStyle) {
// Our mode changed. Exit fullscreen (since we're toggling anyways)
// and then unset the style so that we replace it next time.
oldStyle.exit()
self.fullscreenStyle = nil
// We're done
return
}
// Style is the same.
} else {
// We have no previous style
self.fullscreenStyle = newStyle
}
guard let fullscreenStyle else { return }
if fullscreenStyle.isFullscreen {
fullscreenStyle.exit()
} else {
fullscreenStyle.enter()
}
}
func fullscreenDidChange() {
// For some reason focus can get lost when we change fullscreen. Regardless of
// mode above we just move it back.
if let focusedSurface {
Ghostty.moveFocus(to: focusedSurface)
}
}
// 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)
}
}
}
func windowDidResize(_ notification: Notification) {
windowFrameDidChange()
}
func windowDidMove(_ notification: Notification) {
windowFrameDidChange()
}
// 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)
}
}