mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-03 20:58:36 +03:00
1091 lines
40 KiB
Swift
1091 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
|
|
self.alert = nil
|
|
if response == .alertFirstButtonReturn {
|
|
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 }
|
|
|
|
// If there is a hardcoded title in the configuration, we set that
|
|
// immediately. Future `set_title` apprt actions will override this
|
|
// if necessary but this ensures our window loads with the proper
|
|
// title immediately rather than on another event loop tick (see #5934)
|
|
if let title = derivedConfig.title {
|
|
window.title = title
|
|
}
|
|
|
|
// 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 title: String?
|
|
let macosTitlebarProxyIcon: Ghostty.MacOSTitlebarProxyIcon
|
|
let windowStepResize: Bool
|
|
let focusFollowsMouse: Bool
|
|
|
|
init() {
|
|
self.title = nil
|
|
self.macosTitlebarProxyIcon = .visible
|
|
self.windowStepResize = false
|
|
self.focusFollowsMouse = false
|
|
}
|
|
|
|
init(_ config: Ghostty.Config) {
|
|
self.title = config.title
|
|
self.macosTitlebarProxyIcon = config.macosTitlebarProxyIcon
|
|
self.windowStepResize = config.windowStepResize
|
|
self.focusFollowsMouse = config.focusFollowsMouse
|
|
}
|
|
}
|
|
}
|