mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-09-26 04:58:36 +00:00

Maybe fixes #8736 I thought `windowDidLoad` was early on because its before the window is shown but apparently not. Let's try `awakeFromNib` which is called just after the window is loaded from the nib. It is hard to get any earlier than that.
1084 lines
40 KiB
Swift
1084 lines
40 KiB
Swift
import Cocoa
|
|
import SwiftUI
|
|
import Combine
|
|
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 tree of splits within this terminal window.
|
|
@Published var surfaceTree: SplitTree<Ghostty.SurfaceView> = .init() {
|
|
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
|
|
}
|
|
|
|
/// This can be set to show/hide the command palette.
|
|
@Published var commandPaletteIsShowing: Bool = false
|
|
|
|
/// Whether the terminal surface should focus when the mouse is over it.
|
|
var focusFollowsMouse: Bool {
|
|
self.derivedConfig.focusFollowsMouse
|
|
}
|
|
|
|
/// 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
|
|
|
|
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
|
private var derivedConfig: DerivedConfig
|
|
|
|
/// The cancellables related to our focused surface.
|
|
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
|
|
|
|
/// The time that undo/redo operations that contain running ptys are valid for.
|
|
var undoExpiration: Duration {
|
|
ghostty.config.undoTimeout
|
|
}
|
|
|
|
/// The undo manager for this controller is the undo manager of the window,
|
|
/// which we set via the delegate method.
|
|
override var undoManager: ExpiringUndoManager? {
|
|
// This should be set via the delegate method windowWillReturnUndoManager
|
|
if let result = window?.undoManager as? ExpiringUndoManager {
|
|
return result
|
|
}
|
|
|
|
// If the window one isn't set, we fallback to our global one.
|
|
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
|
|
return appDelegate.undoManager
|
|
}
|
|
|
|
return 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: SplitTree<Ghostty.SurfaceView>? = nil
|
|
) {
|
|
self.ghostty = ghostty
|
|
self.derivedConfig = DerivedConfig(ghostty.config)
|
|
|
|
super.init(window: nil)
|
|
|
|
// Initialize our initial surface.
|
|
guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") }
|
|
self.surfaceTree = tree ?? .init(view: Ghostty.SurfaceView(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)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyConfigDidChangeBase(_:)),
|
|
name: .ghosttyConfigDidChange,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyCommandPaletteDidToggle(_:)),
|
|
name: .ghosttyCommandPaletteDidToggle,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyMaximizeDidToggle(_:)),
|
|
name: .ghosttyMaximizeDidToggle,
|
|
object: nil)
|
|
|
|
// Splits
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyDidCloseSurface(_:)),
|
|
name: Ghostty.Notification.ghosttyCloseSurface,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyDidNewSplit(_:)),
|
|
name: Ghostty.Notification.ghosttyNewSplit,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyDidEqualizeSplits(_:)),
|
|
name: Ghostty.Notification.didEqualizeSplits,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyDidFocusSplit(_:)),
|
|
name: Ghostty.Notification.ghosttyFocusSplit,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyDidToggleSplitZoom(_:)),
|
|
name: Ghostty.Notification.didToggleSplitZoom,
|
|
object: nil)
|
|
center.addObserver(
|
|
self,
|
|
selector: #selector(ghosttyDidResizeSplit(_:)),
|
|
name: Ghostty.Notification.didResizeSplit,
|
|
object: nil)
|
|
|
|
// Listen for local events that we need to know of outside of
|
|
// single surface handlers.
|
|
self.eventMonitor = NSEvent.addLocalMonitorForEvents(
|
|
matching: [.flagsChanged]
|
|
) { [weak self] event in self?.localEventHandler(event) }
|
|
}
|
|
|
|
deinit {
|
|
NotificationCenter.default.removeObserver(self)
|
|
undoManager?.removeAllActions(withTarget: self)
|
|
if let eventMonitor {
|
|
NSEvent.removeMonitor(eventMonitor)
|
|
}
|
|
}
|
|
|
|
// MARK: Methods
|
|
|
|
/// Create a new split.
|
|
@discardableResult
|
|
func newSplit(
|
|
at oldView: Ghostty.SurfaceView,
|
|
direction: SplitTree<Ghostty.SurfaceView>.NewDirection,
|
|
baseConfig config: Ghostty.SurfaceConfiguration? = nil
|
|
) -> Ghostty.SurfaceView? {
|
|
// We can only create new splits for surfaces in our tree.
|
|
guard surfaceTree.root?.node(view: oldView) != nil else { return nil }
|
|
|
|
// Create a new surface view
|
|
guard let ghostty_app = ghostty.app else { return nil }
|
|
let newView = Ghostty.SurfaceView(ghostty_app, baseConfig: config)
|
|
|
|
// Do the split
|
|
let newTree: SplitTree<Ghostty.SurfaceView>
|
|
do {
|
|
newTree = try surfaceTree.insert(
|
|
view: newView,
|
|
at: oldView,
|
|
direction: direction)
|
|
} catch {
|
|
// If splitting fails for any reason (it should not), then we just log
|
|
// and return. The new view we created will be deinitialized and its
|
|
// no big deal.
|
|
Ghostty.logger.warning("failed to insert split: \(error)")
|
|
return nil
|
|
}
|
|
|
|
replaceSurfaceTree(
|
|
newTree,
|
|
moveFocusTo: newView,
|
|
moveFocusFrom: oldView,
|
|
undoAction: "New Split")
|
|
|
|
return newView
|
|
}
|
|
|
|
/// Called when the surfaceTree variable changed.
|
|
///
|
|
/// Subclasses should call super first.
|
|
func surfaceTreeDidChange(from: SplitTree<Ghostty.SurfaceView>, to: SplitTree<Ghostty.SurfaceView>) {
|
|
// If our surface tree becomes empty then we have no focused surface.
|
|
if (to.isEmpty) {
|
|
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() {
|
|
for surfaceView in surfaceTree {
|
|
// Our focus state requires that this window is key and our currently
|
|
// focused surface is the surface in this view.
|
|
let focused: Bool = (window?.isKeyWindow ?? false) &&
|
|
!commandPaletteIsShowing &&
|
|
focusedSurface != nil &&
|
|
surfaceView == focusedSurface!
|
|
surfaceView.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)
|
|
}
|
|
|
|
func confirmClose(
|
|
messageText: String,
|
|
informativeText: String,
|
|
completion: @escaping () -> Void
|
|
) {
|
|
// If we already have an alert, we need to wait for that one.
|
|
guard alert == nil else { return }
|
|
|
|
// If there is no window to attach the modal then we assume success
|
|
// since we'll never be able to show the modal.
|
|
guard let window else {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
// If we need confirmation by any, show one confirmation for all windows
|
|
// in the tab group.
|
|
let alert = NSAlert()
|
|
alert.messageText = messageText
|
|
alert.informativeText = informativeText
|
|
alert.addButton(withTitle: "Close")
|
|
alert.addButton(withTitle: "Cancel")
|
|
alert.alertStyle = .warning
|
|
alert.beginSheetModal(for: window) { response in
|
|
let alertWindow = alert.window
|
|
self.alert = nil
|
|
if response == .alertFirstButtonReturn {
|
|
// This is important so that we avoid losing focus when Stage
|
|
// Manager is used (#8336)
|
|
alertWindow.orderOut(nil)
|
|
completion()
|
|
}
|
|
}
|
|
|
|
// Store our alert so we only ever show one.
|
|
self.alert = alert
|
|
}
|
|
|
|
/// Close a surface from a view.
|
|
func closeSurface(
|
|
_ view: Ghostty.SurfaceView,
|
|
withConfirmation: Bool = true
|
|
) {
|
|
guard let node = surfaceTree.root?.node(view: view) else { return }
|
|
closeSurface(node, withConfirmation: withConfirmation)
|
|
}
|
|
|
|
/// Close a surface node (which may contain splits), requesting confirmation if necessary.
|
|
///
|
|
/// This will also insert the proper undo stack information in.
|
|
func closeSurface(
|
|
_ node: SplitTree<Ghostty.SurfaceView>.Node,
|
|
withConfirmation: Bool = true
|
|
) {
|
|
// This node must be part of our tree
|
|
guard surfaceTree.contains(node) else { return }
|
|
|
|
// If the child process is not alive, then we exit immediately
|
|
guard withConfirmation else {
|
|
removeSurfaceNode(node)
|
|
return
|
|
}
|
|
|
|
// Confirm close. We use an NSAlert instead of a SwiftUI confirmationDialog
|
|
// due to SwiftUI bugs (see Ghostty #560). To repeat from #560, the bug is that
|
|
// confirmationDialog allows the user to Cmd-W close the alert, but when doing
|
|
// so SwiftUI does not update any of the bindings to note that window is no longer
|
|
// being shown, and provides no callback to detect this.
|
|
confirmClose(
|
|
messageText: "Close Terminal?",
|
|
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
|
|
) { [weak self] in
|
|
if let self {
|
|
self.removeSurfaceNode(node)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Split Tree Management
|
|
|
|
/// Find the next surface to focus when a node is being closed.
|
|
/// Goes to previous split unless we're the leftmost leaf, then goes to next.
|
|
private func findNextFocusTargetAfterClosing(node: SplitTree<Ghostty.SurfaceView>.Node) -> Ghostty.SurfaceView? {
|
|
guard let root = surfaceTree.root else { return nil }
|
|
|
|
// If we're the leftmost, then we move to the next surface after closing.
|
|
// Otherwise, we move to the previous.
|
|
if root.leftmostLeaf() == node.leftmostLeaf() {
|
|
return surfaceTree.focusTarget(for: .next, from: node)
|
|
} else {
|
|
return surfaceTree.focusTarget(for: .previous, from: node)
|
|
}
|
|
}
|
|
|
|
/// Remove a node from the surface tree and move focus appropriately.
|
|
///
|
|
/// This also updates the undo manager to support restoring this node.
|
|
///
|
|
/// This does no confirmation and assumes confirmation is already done.
|
|
private func removeSurfaceNode(_ node: SplitTree<Ghostty.SurfaceView>.Node) {
|
|
// Move focus if the closed surface was focused and we have a next target
|
|
let nextFocus: Ghostty.SurfaceView? = if node.contains(
|
|
where: { $0 == focusedSurface }
|
|
) {
|
|
findNextFocusTargetAfterClosing(node: node)
|
|
} else {
|
|
nil
|
|
}
|
|
|
|
replaceSurfaceTree(
|
|
surfaceTree.remove(node),
|
|
moveFocusTo: nextFocus,
|
|
moveFocusFrom: focusedSurface,
|
|
undoAction: "Close Terminal"
|
|
)
|
|
}
|
|
|
|
private func replaceSurfaceTree(
|
|
_ newTree: SplitTree<Ghostty.SurfaceView>,
|
|
moveFocusTo newView: Ghostty.SurfaceView? = nil,
|
|
moveFocusFrom oldView: Ghostty.SurfaceView? = nil,
|
|
undoAction: String? = nil
|
|
) {
|
|
// Setup our new split tree
|
|
let oldTree = surfaceTree
|
|
surfaceTree = newTree
|
|
if let newView {
|
|
DispatchQueue.main.async {
|
|
Ghostty.moveFocus(to: newView, from: oldView)
|
|
}
|
|
}
|
|
|
|
// Setup our undo
|
|
if let undoManager {
|
|
if let undoAction {
|
|
undoManager.setActionName(undoAction)
|
|
}
|
|
undoManager.registerUndo(
|
|
withTarget: self,
|
|
expiresAfter: undoExpiration
|
|
) { target in
|
|
target.surfaceTree = oldTree
|
|
if let oldView {
|
|
DispatchQueue.main.async {
|
|
Ghostty.moveFocus(to: oldView, from: target.focusedSurface)
|
|
}
|
|
}
|
|
|
|
undoManager.registerUndo(
|
|
withTarget: target,
|
|
expiresAfter: target.undoExpiration
|
|
) { target in
|
|
target.replaceSurfaceTree(
|
|
newTree,
|
|
moveFocusTo: newView,
|
|
moveFocusFrom: target.focusedSurface,
|
|
undoAction: undoAction)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
@objc private func ghosttyConfigDidChangeBase(_ notification: Notification) {
|
|
// We only care if the configuration is a global configuration, not a
|
|
// surface-specific one.
|
|
guard notification.object == nil else { return }
|
|
|
|
// Get our managed configuration object out
|
|
guard let config = notification.userInfo?[
|
|
Notification.Name.GhosttyConfigChangeKey
|
|
] as? Ghostty.Config else { return }
|
|
|
|
// Update our derived config
|
|
self.derivedConfig = DerivedConfig(config)
|
|
}
|
|
|
|
@objc private func ghosttyCommandPaletteDidToggle(_ notification: Notification) {
|
|
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard surfaceTree.contains(surfaceView) else { return }
|
|
toggleCommandPalette(nil)
|
|
}
|
|
|
|
@objc private func ghosttyMaximizeDidToggle(_ notification: Notification) {
|
|
guard let window else { return }
|
|
guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard surfaceTree.contains(surfaceView) else { return }
|
|
window.zoom(nil)
|
|
}
|
|
|
|
@objc private func ghosttyDidCloseSurface(_ notification: Notification) {
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard let node = surfaceTree.root?.node(view: target) else { return }
|
|
closeSurface(
|
|
node,
|
|
withConfirmation: (notification.userInfo?["process_alive"] as? Bool) ?? false)
|
|
}
|
|
|
|
@objc private func ghosttyDidNewSplit(_ notification: Notification) {
|
|
// The target must be within our tree
|
|
guard let oldView = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard surfaceTree.root?.node(view: oldView) != nil else { return }
|
|
|
|
// Notification must contain our base config
|
|
let configAny = notification.userInfo?[Ghostty.Notification.NewSurfaceConfigKey]
|
|
let config = configAny as? Ghostty.SurfaceConfiguration
|
|
|
|
// Determine our desired direction
|
|
guard let directionAny = notification.userInfo?["direction"] else { return }
|
|
guard let direction = directionAny as? ghostty_action_split_direction_e else { return }
|
|
let splitDirection: SplitTree<Ghostty.SurfaceView>.NewDirection
|
|
switch (direction) {
|
|
case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .right
|
|
case GHOSTTY_SPLIT_DIRECTION_LEFT: splitDirection = .left
|
|
case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .down
|
|
case GHOSTTY_SPLIT_DIRECTION_UP: splitDirection = .up
|
|
default: return
|
|
}
|
|
|
|
newSplit(at: oldView, direction: splitDirection, baseConfig: config)
|
|
}
|
|
|
|
@objc private func ghosttyDidEqualizeSplits(_ notification: Notification) {
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
|
|
// Check if target surface is in current controller's tree
|
|
guard surfaceTree.contains(target) else { return }
|
|
|
|
// Equalize the splits
|
|
surfaceTree = surfaceTree.equalize()
|
|
}
|
|
|
|
@objc private func ghosttyDidFocusSplit(_ notification: Notification) {
|
|
// The target must be within our tree
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard surfaceTree.root?.node(view: target) != nil else { return }
|
|
|
|
// Get the direction from the notification
|
|
guard let directionAny = notification.userInfo?[Ghostty.Notification.SplitDirectionKey] else { return }
|
|
guard let direction = directionAny as? Ghostty.SplitFocusDirection else { return }
|
|
|
|
// Convert Ghostty.SplitFocusDirection to our SplitTree.FocusDirection
|
|
let focusDirection: SplitTree<Ghostty.SurfaceView>.FocusDirection
|
|
switch direction {
|
|
case .previous: focusDirection = .previous
|
|
case .next: focusDirection = .next
|
|
case .up: focusDirection = .spatial(.up)
|
|
case .down: focusDirection = .spatial(.down)
|
|
case .left: focusDirection = .spatial(.left)
|
|
case .right: focusDirection = .spatial(.right)
|
|
}
|
|
|
|
// Find the node for the target surface
|
|
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
|
|
|
// Find the next surface to focus
|
|
guard let nextSurface = surfaceTree.focusTarget(for: focusDirection, from: targetNode) else {
|
|
return
|
|
}
|
|
|
|
// Remove the zoomed state for this surface tree.
|
|
if surfaceTree.zoomed != nil {
|
|
surfaceTree = .init(root: surfaceTree.root, zoomed: nil)
|
|
}
|
|
|
|
// Move focus to the next surface
|
|
DispatchQueue.main.async {
|
|
Ghostty.moveFocus(to: nextSurface, from: target)
|
|
}
|
|
}
|
|
|
|
@objc private func ghosttyDidToggleSplitZoom(_ notification: Notification) {
|
|
// The target must be within our tree
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
|
|
|
// Toggle the zoomed state
|
|
if surfaceTree.zoomed == targetNode {
|
|
// Already zoomed, unzoom it
|
|
surfaceTree = SplitTree(root: surfaceTree.root, zoomed: nil)
|
|
} else {
|
|
// We require that the split tree have splits
|
|
guard surfaceTree.isSplit else { return }
|
|
|
|
// Not zoomed or different node zoomed, zoom this node
|
|
surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode)
|
|
}
|
|
|
|
// Move focus to our window. Importantly this ensures that if we click the
|
|
// reset zoom button in a tab bar of an unfocused tab that we become focused.
|
|
window?.makeKeyAndOrderFront(nil)
|
|
|
|
// Ensure focus stays on the target surface. We lose focus when we do
|
|
// this so we need to grab it again.
|
|
DispatchQueue.main.async {
|
|
Ghostty.moveFocus(to: target)
|
|
}
|
|
}
|
|
|
|
@objc private func ghosttyDidResizeSplit(_ notification: Notification) {
|
|
// The target must be within our tree
|
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
|
guard let targetNode = surfaceTree.root?.node(view: target) else { return }
|
|
|
|
// Extract direction and amount from notification
|
|
guard let directionAny = notification.userInfo?[Ghostty.Notification.ResizeSplitDirectionKey] else { return }
|
|
guard let direction = directionAny as? Ghostty.SplitResizeDirection else { return }
|
|
|
|
guard let amountAny = notification.userInfo?[Ghostty.Notification.ResizeSplitAmountKey] else { return }
|
|
guard let amount = amountAny as? UInt16 else { return }
|
|
|
|
// Convert Ghostty.SplitResizeDirection to SplitTree.Spatial.Direction
|
|
let spatialDirection: SplitTree<Ghostty.SurfaceView>.Spatial.Direction
|
|
switch direction {
|
|
case .up: spatialDirection = .up
|
|
case .down: spatialDirection = .down
|
|
case .left: spatialDirection = .left
|
|
case .right: spatialDirection = .right
|
|
}
|
|
|
|
// Use viewBounds for the spatial calculation bounds
|
|
let bounds = CGRect(origin: .zero, size: surfaceTree.viewBounds())
|
|
|
|
// Perform the resize using the new SplitTree resize method
|
|
do {
|
|
surfaceTree = try surfaceTree.resize(node: targetNode, by: amount, in: spatialDirection, with: bounds)
|
|
} catch {
|
|
Ghostty.logger.warning("failed to resize split: \(error)")
|
|
}
|
|
}
|
|
|
|
// 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? {
|
|
var surfaces: [Ghostty.SurfaceView] = surfaceTree.map { $0 }
|
|
|
|
// 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
|
|
|
|
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
|
|
let lastFocusedSurface = focusedSurface
|
|
focusedSurface = to
|
|
|
|
// Important to cancel any prior subscriptions
|
|
focusedSurfaceCancellables = []
|
|
|
|
// Setup our title listener. If we have a focused surface we always use that.
|
|
// Otherwise, we try to use our last focused surface. In either case, we only
|
|
// want to care if the surface is in the tree so we don't listen to titles of
|
|
// closed surfaces.
|
|
if let titleSurface = focusedSurface ?? lastFocusedSurface,
|
|
surfaceTree.contains(titleSurface) {
|
|
// If we have a surface, we want to listen for title changes.
|
|
titleSurface.$title
|
|
.sink { [weak self] in self?.titleDidChange(to: $0) }
|
|
.store(in: &focusedSurfaceCancellables)
|
|
} else {
|
|
// There is no surface to listen to titles for.
|
|
titleDidChange(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 derivedConfig.macosTitlebarProxyIcon == .visible {
|
|
// Use the 'to' URL directly
|
|
window.representedURL = to
|
|
} else {
|
|
window.representedURL = nil
|
|
}
|
|
}
|
|
|
|
|
|
func cellSizeDidChange(to: NSSize) {
|
|
guard derivedConfig.windowStepResize else { return }
|
|
self.window?.contentResizeIncrements = to
|
|
}
|
|
|
|
func splitDidResize(node: SplitTree<Ghostty.SurfaceView>.Node, to newRatio: Double) {
|
|
let resizedNode = node.resize(to: newRatio)
|
|
do {
|
|
surfaceTree = try surfaceTree.replace(node: node, with: resizedNode)
|
|
} catch {
|
|
Ghostty.logger.warning("failed to replace node during split resize: \(error)")
|
|
return
|
|
}
|
|
}
|
|
|
|
func performAction(_ action: String, on surfaceView: Ghostty.SurfaceView) {
|
|
guard let surface = surfaceView.surface else { return }
|
|
let len = action.utf8CString.count
|
|
if (len == 0) { return }
|
|
_ = action.withCString { cString in
|
|
ghostty_surface_binding_action(surface, cString, UInt(len - 1))
|
|
}
|
|
}
|
|
|
|
// 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 set the new style for future use
|
|
oldStyle.exit()
|
|
self.fullscreenStyle = newStyle
|
|
|
|
// 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() {}
|
|
|
|
// 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 let .osc_52_write(pasteboard):
|
|
guard case .confirm = action else { break }
|
|
let pb = pasteboard ?? 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: NSWindowController
|
|
|
|
override func windowDidLoad() {
|
|
super.windowDidLoad()
|
|
|
|
// Setup our undo manager.
|
|
|
|
// Everything beyond here is setting up the window
|
|
guard let window else { return }
|
|
|
|
// We always initialize our fullscreen style to native if we can because
|
|
// initialization sets up some state (i.e. observers). If its set already
|
|
// somehow we don't do this.
|
|
if fullscreenStyle == nil {
|
|
fullscreenStyle = NativeFullscreen(window)
|
|
fullscreenStyle?.delegate = self
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
if surfaceTree.isEmpty { 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 !surfaceTree.contains(where: { $0.needsConfirmQuit }) { return true }
|
|
|
|
// We require confirmation, so show an alert as long as we aren't already.
|
|
confirmClose(
|
|
messageText: "Close Terminal?",
|
|
informativeText: "The terminal still has a running process. If you close the terminal the process will be killed."
|
|
) {
|
|
window.close()
|
|
}
|
|
|
|
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
|
|
|
|
// Make sure we clean up all our undos
|
|
window.undoManager?.removeAllActions(withTarget: self)
|
|
}
|
|
|
|
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) {
|
|
let visible = self.window?.occlusionState.contains(.visible) ?? false
|
|
for view in surfaceTree {
|
|
if let surface = view.surface {
|
|
ghostty_surface_set_occlusion(surface, visible)
|
|
}
|
|
}
|
|
}
|
|
|
|
func windowDidResize(_ notification: Notification) {
|
|
windowFrameDidChange()
|
|
}
|
|
|
|
func windowDidMove(_ notification: Notification) {
|
|
windowFrameDidChange()
|
|
}
|
|
|
|
func windowWillReturnUndoManager(_ window: NSWindow) -> UndoManager? {
|
|
guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil }
|
|
return appDelegate.undoManager
|
|
}
|
|
|
|
// 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 splitLeft(_ sender: Any) {
|
|
guard let surface = focusedSurface?.surface else { return }
|
|
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_LEFT)
|
|
}
|
|
|
|
@IBAction func splitDown(_ sender: Any) {
|
|
guard let surface = focusedSurface?.surface else { return }
|
|
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_DOWN)
|
|
}
|
|
|
|
@IBAction func splitUp(_ sender: Any) {
|
|
guard let surface = focusedSurface?.surface else { return }
|
|
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_UP)
|
|
}
|
|
|
|
@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: .up)
|
|
}
|
|
|
|
@IBAction func splitMoveFocusBelow(_ sender: Any) {
|
|
splitMoveFocus(direction: .down)
|
|
}
|
|
|
|
@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)
|
|
}
|
|
|
|
@IBAction func toggleCommandPalette(_ sender: Any?) {
|
|
commandPaletteIsShowing.toggle()
|
|
}
|
|
|
|
@objc func resetTerminal(_ sender: Any) {
|
|
guard let surface = focusedSurface?.surface else { return }
|
|
ghostty.resetTerminal(surface: surface)
|
|
}
|
|
|
|
private struct DerivedConfig {
|
|
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
|
|
let windowStepResize: Bool
|
|
let focusFollowsMouse: Bool
|
|
|
|
init() {
|
|
self.macosTitlebarProxyIcon = .visible
|
|
self.windowStepResize = false
|
|
self.focusFollowsMouse = false
|
|
}
|
|
|
|
init(_ config: Ghostty.Config) {
|
|
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
|
|
self.windowStepResize = config.windowStepResize
|
|
self.focusFollowsMouse = config.focusFollowsMouse
|
|
}
|
|
}
|
|
}
|