mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
Add Tab for QuickTerminal
This commit is contained in:
@ -103,6 +103,10 @@
|
|||||||
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; };
|
C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EA62B738B9900404083 /* NSView+Extension.swift */; };
|
||||||
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EE82B76CBFC00404083 /* VibrantLayer.m */; };
|
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */ = {isa = PBXBuildFile; fileRef = C1F26EE82B76CBFC00404083 /* VibrantLayer.m */; };
|
||||||
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */; };
|
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */; };
|
||||||
|
CF41FAD12D26AADB004A0BF7 /* QuickTerminalTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF41FAD02D26AADB004A0BF7 /* QuickTerminalTab.swift */; };
|
||||||
|
CF41FAD32D26AB35004A0BF7 /* QuickTerminalTabManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF41FAD22D26AB35004A0BF7 /* QuickTerminalTabManager.swift */; };
|
||||||
|
CF41FAD62D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF41FAD52D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift */; };
|
||||||
|
CF41FAD82D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CF41FAD72D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift */; };
|
||||||
FC5218FA2D10FFCE004C93E0 /* zsh in Resources */ = {isa = PBXBuildFile; fileRef = FC5218F92D10FFC7004C93E0 /* zsh */; };
|
FC5218FA2D10FFCE004C93E0 /* zsh in Resources */ = {isa = PBXBuildFile; fileRef = FC5218F92D10FFC7004C93E0 /* zsh */; };
|
||||||
FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; };
|
FC9ABA9C2D0F53F80020D4C8 /* bash-completion in Resources */ = {isa = PBXBuildFile; fileRef = FC9ABA9B2D0F538D0020D4C8 /* bash-completion */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
@ -200,6 +204,10 @@
|
|||||||
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VibrantLayer.m; sourceTree = "<group>"; };
|
C1F26EE82B76CBFC00404083 /* VibrantLayer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = VibrantLayer.m; sourceTree = "<group>"; };
|
||||||
C1F26EEA2B76CC2400404083 /* ghostty-bridging-header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "ghostty-bridging-header.h"; sourceTree = "<group>"; };
|
C1F26EEA2B76CC2400404083 /* ghostty-bridging-header.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "ghostty-bridging-header.h"; sourceTree = "<group>"; };
|
||||||
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalSpaceBehavior.swift; sourceTree = "<group>"; };
|
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalSpaceBehavior.swift; sourceTree = "<group>"; };
|
||||||
|
CF41FAD02D26AADB004A0BF7 /* QuickTerminalTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTab.swift; sourceTree = "<group>"; };
|
||||||
|
CF41FAD22D26AB35004A0BF7 /* QuickTerminalTabManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTabManager.swift; sourceTree = "<group>"; };
|
||||||
|
CF41FAD52D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTabBarView.swift; sourceTree = "<group>"; };
|
||||||
|
CF41FAD72D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalTabItemView.swift; sourceTree = "<group>"; };
|
||||||
FC5218F92D10FFC7004C93E0 /* zsh */ = {isa = PBXFileReference; lastKnownFileType = folder; name = zsh; path = "../zig-out/share/zsh"; sourceTree = "<group>"; };
|
FC5218F92D10FFC7004C93E0 /* zsh */ = {isa = PBXFileReference; lastKnownFileType = folder; name = zsh; path = "../zig-out/share/zsh"; sourceTree = "<group>"; };
|
||||||
FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = "<group>"; };
|
FC9ABA9B2D0F538D0020D4C8 /* bash-completion */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "bash-completion"; path = "../zig-out/share/bash-completion"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@ -448,6 +456,7 @@
|
|||||||
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = {
|
A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
CF41FAD42D26AB7F004A0BF7 /* Tab */,
|
||||||
A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */,
|
A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */,
|
||||||
A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */,
|
A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */,
|
||||||
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */,
|
CFBB5FE92D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift */,
|
||||||
@ -494,6 +503,17 @@
|
|||||||
path = ClipboardConfirmation;
|
path = ClipboardConfirmation;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
CF41FAD42D26AB7F004A0BF7 /* Tab */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
CF41FAD02D26AADB004A0BF7 /* QuickTerminalTab.swift */,
|
||||||
|
CF41FAD52D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift */,
|
||||||
|
CF41FAD72D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift */,
|
||||||
|
CF41FAD22D26AB35004A0BF7 /* QuickTerminalTabManager.swift */,
|
||||||
|
);
|
||||||
|
path = Tab;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@ -617,6 +637,7 @@
|
|||||||
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
|
A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */,
|
||||||
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
|
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */,
|
||||||
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
|
A54B0CEB2D0CFB4C00CBEFF8 /* NSImage+Extension.swift in Sources */,
|
||||||
|
CF41FAD32D26AB35004A0BF7 /* QuickTerminalTabManager.swift in Sources */,
|
||||||
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
|
A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */,
|
||||||
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
|
A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */,
|
||||||
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
|
CFBB5FEA2D231E5000FD62EE /* QuickTerminalSpaceBehavior.swift in Sources */,
|
||||||
@ -636,13 +657,16 @@
|
|||||||
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
|
A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */,
|
||||||
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
|
A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */,
|
||||||
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
|
C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */,
|
||||||
|
CF41FAD12D26AADB004A0BF7 /* QuickTerminalTab.swift in Sources */,
|
||||||
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
|
A59630972AEE163600D64628 /* HostingWindow.swift in Sources */,
|
||||||
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
|
A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */,
|
||||||
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
|
A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */,
|
||||||
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
|
A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */,
|
||||||
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
|
AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */,
|
||||||
|
CF41FAD62D26ABC9004A0BF7 /* QuickTerminalTabBarView.swift in Sources */,
|
||||||
A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */,
|
A52FFF5D2CAB4D08000C6A5B /* NSScreen+Extension.swift in Sources */,
|
||||||
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
|
A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */,
|
||||||
|
CF41FAD82D26ABF6004A0BF7 /* QuickTerminalTabItemView.swift in Sources */,
|
||||||
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
|
A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */,
|
||||||
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */,
|
A5A6F72A2CC41B8900B232A5 /* Xcode.swift in Sources */,
|
||||||
A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */,
|
A52FFF5B2CAA54B1000C6A5B /* FullscreenMode+Extension.swift in Sources */,
|
||||||
|
@ -30,10 +30,17 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
/// The configuration derived from the Ghostty config so we don't need to rely on references.
|
||||||
private var derivedConfig: DerivedConfig
|
private var derivedConfig: DerivedConfig
|
||||||
|
|
||||||
init(_ ghostty: Ghostty.App,
|
// The tab manager for the quick terminal
|
||||||
position: QuickTerminalPosition = .top,
|
private lazy var tabManager: QuickTerminalTabManager = {
|
||||||
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
let manager = QuickTerminalTabManager(controller: self)
|
||||||
surfaceTree tree: Ghostty.SplitNode? = nil
|
return manager
|
||||||
|
}()
|
||||||
|
|
||||||
|
init(
|
||||||
|
_ ghostty: Ghostty.App,
|
||||||
|
position: QuickTerminalPosition = .top,
|
||||||
|
baseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||||
|
surfaceTree tree: Ghostty.SplitNode? = nil
|
||||||
) {
|
) {
|
||||||
self.position = position
|
self.position = position
|
||||||
self.derivedConfig = DerivedConfig(ghostty.config)
|
self.derivedConfig = DerivedConfig(ghostty.config)
|
||||||
@ -51,6 +58,18 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
selector: #selector(ghosttyConfigDidChange(_:)),
|
selector: #selector(ghosttyConfigDidChange(_:)),
|
||||||
name: .ghosttyConfigDidChange,
|
name: .ghosttyConfigDidChange,
|
||||||
object: nil)
|
object: nil)
|
||||||
|
|
||||||
|
// Setup our notifications for tab behaviors
|
||||||
|
center.addObserver(
|
||||||
|
tabManager,
|
||||||
|
selector: #selector(tabManager.onMoveTab(_:)),
|
||||||
|
name: .ghosttyMoveTab,
|
||||||
|
object: nil)
|
||||||
|
center.addObserver(
|
||||||
|
tabManager,
|
||||||
|
selector: #selector(tabManager.onGoToTab(_:)),
|
||||||
|
name: Ghostty.Notification.ghosttyGotoTab,
|
||||||
|
object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
required init?(coder: NSCoder) {
|
||||||
@ -83,15 +102,34 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
// Setup our initial size based on our configured position
|
// Setup our initial size based on our configured position
|
||||||
position.setLoaded(window)
|
position.setLoaded(window)
|
||||||
|
|
||||||
// Setup our content
|
DispatchQueue.main.async {
|
||||||
window.contentView = NSHostingView(rootView: TerminalView(
|
self.setupMainView()
|
||||||
ghostty: self.ghostty,
|
self.animateIn()
|
||||||
viewModel: self,
|
}
|
||||||
delegate: self
|
}
|
||||||
))
|
|
||||||
|
|
||||||
// Animate the window in
|
private func setupMainView() {
|
||||||
animateIn()
|
guard let window = self.window else { return }
|
||||||
|
|
||||||
|
let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil)
|
||||||
|
let surface: Ghostty.SplitNode = .leaf(leaf)
|
||||||
|
let initialTab = QuickTerminalTab(surface: surface)
|
||||||
|
initialTab.isActive = true
|
||||||
|
tabManager.tabs.append(initialTab)
|
||||||
|
tabManager.currentTab = initialTab
|
||||||
|
surfaceTree = surface
|
||||||
|
focusedSurface = leaf.surface
|
||||||
|
|
||||||
|
let mainContent = VStack(spacing: 0) {
|
||||||
|
TabBarView(tabManager: tabManager)
|
||||||
|
TerminalView(
|
||||||
|
ghostty: ghostty,
|
||||||
|
viewModel: self,
|
||||||
|
delegate: self
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.contentView = NSHostingView(rootView: mainContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: NSWindowDelegate
|
// MARK: NSWindowDelegate
|
||||||
@ -153,16 +191,22 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
|
override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) {
|
||||||
super.surfaceTreeDidChange(from: from, to: to)
|
super.surfaceTreeDidChange(from: from, to: to)
|
||||||
|
|
||||||
// If our surface tree is nil then we animate the window out.
|
// If we have a tab with surfaces removed from surfaceTree, we need to remove them from the tab manager by calling closeTab
|
||||||
if (to == nil) {
|
if to == nil {
|
||||||
animateOut()
|
tabManager.tabs
|
||||||
|
.filter { tab in
|
||||||
|
tab.surface.contains { $0.surface.surface == nil }
|
||||||
|
}
|
||||||
|
.forEach { tab in
|
||||||
|
tabManager.closeTab(tab)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Methods
|
// MARK: Methods
|
||||||
|
|
||||||
func toggle() {
|
func toggle() {
|
||||||
if (visible) {
|
if visible {
|
||||||
animateOut()
|
animateOut()
|
||||||
} else {
|
} else {
|
||||||
animateIn()
|
animateIn()
|
||||||
@ -186,7 +230,7 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
// we want to store it so we can restore state later.
|
// we want to store it so we can restore state later.
|
||||||
if !NSApp.isActive {
|
if !NSApp.isActive {
|
||||||
if let previousApp = NSWorkspace.shared.frontmostApplication,
|
if let previousApp = NSWorkspace.shared.frontmostApplication,
|
||||||
previousApp.bundleIdentifier != Bundle.main.bundleIdentifier
|
previousApp.bundleIdentifier != Bundle.main.bundleIdentifier
|
||||||
{
|
{
|
||||||
self.previousApp = previousApp
|
self.previousApp = previousApp
|
||||||
}
|
}
|
||||||
@ -201,7 +245,7 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
// If our surface tree is nil then we initialize a new terminal. The surface
|
// If our surface tree is nil then we initialize a new terminal. The surface
|
||||||
// tree can be nil if for example we run "eixt" in the terminal and force
|
// tree can be nil if for example we run "eixt" in the terminal and force
|
||||||
// animate out.
|
// animate out.
|
||||||
if (surfaceTree == nil) {
|
if surfaceTree == nil {
|
||||||
let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil)
|
let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil)
|
||||||
surfaceTree = .leaf(leaf)
|
surfaceTree = .leaf(leaf)
|
||||||
focusedSurface = leaf.surface
|
focusedSurface = leaf.surface
|
||||||
@ -237,46 +281,48 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
|
|
||||||
// Run the animation that moves our window into the proper place and makes
|
// Run the animation that moves our window into the proper place and makes
|
||||||
// it visible.
|
// it visible.
|
||||||
NSAnimationContext.runAnimationGroup({ context in
|
NSAnimationContext.runAnimationGroup(
|
||||||
context.duration = derivedConfig.quickTerminalAnimationDuration
|
{ context in
|
||||||
context.timingFunction = .init(name: .easeIn)
|
context.duration = derivedConfig.quickTerminalAnimationDuration
|
||||||
position.setFinal(in: window.animator(), on: screen)
|
context.timingFunction = .init(name: .easeIn)
|
||||||
}, completionHandler: {
|
position.setFinal(in: window.animator(), on: screen)
|
||||||
// There is a very minor delay here so waiting at least an event loop tick
|
},
|
||||||
// keeps us safe from the view not being on the window.
|
completionHandler: {
|
||||||
DispatchQueue.main.async {
|
// There is a very minor delay here so waiting at least an event loop tick
|
||||||
// If we canceled our animation in we do nothing
|
// keeps us safe from the view not being on the window.
|
||||||
guard self.visible else { return }
|
DispatchQueue.main.async {
|
||||||
|
// If we canceled our animation in we do nothing
|
||||||
|
guard self.visible else { return }
|
||||||
|
|
||||||
// Now that the window is visible, sync our appearance. This function
|
// Now that the window is visible, sync our appearance. This function
|
||||||
// requires the window is visible.
|
// requires the window is visible.
|
||||||
self.syncAppearance()
|
self.syncAppearance()
|
||||||
|
|
||||||
// Once our animation is done, we must grab focus since we can't grab
|
// Once our animation is done, we must grab focus since we can't grab
|
||||||
// focus of a non-visible window.
|
// focus of a non-visible window.
|
||||||
self.makeWindowKey(window)
|
self.makeWindowKey(window)
|
||||||
|
|
||||||
// If our application is not active, then we grab focus. Its important
|
// If our application is not active, then we grab focus. Its important
|
||||||
// we do this AFTER our window is animated in and focused because
|
// we do this AFTER our window is animated in and focused because
|
||||||
// otherwise macOS will bring forward another window.
|
// otherwise macOS will bring forward another window.
|
||||||
if !NSApp.isActive {
|
if !NSApp.isActive {
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
NSApp.activate(ignoringOtherApps: true)
|
||||||
|
|
||||||
// This works around a really funky bug where if the terminal is
|
// This works around a really funky bug where if the terminal is
|
||||||
// shown on a screen that has no other Ghostty windows, it takes
|
// shown on a screen that has no other Ghostty windows, it takes
|
||||||
// a few (variable) event loop ticks until we can actually focus it.
|
// a few (variable) event loop ticks until we can actually focus it.
|
||||||
// https://github.com/ghostty-org/ghostty/issues/2409
|
// https://github.com/ghostty-org/ghostty/issues/2409
|
||||||
//
|
//
|
||||||
// We wait one event loop tick to try it because under the happy
|
// We wait one event loop tick to try it because under the happy
|
||||||
// path (we have windows on this screen) it takes one event loop
|
// path (we have windows on this screen) it takes one event loop
|
||||||
// tick for window.isKeyWindow to return true.
|
// tick for window.isKeyWindow to return true.
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard !window.isKeyWindow else { return }
|
guard !window.isKeyWindow else { return }
|
||||||
self.makeWindowKey(window, retries: 10)
|
self.makeWindowKey(window, retries: 10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Attempt to make a window key, supporting retries if necessary. The retries will be attempted
|
/// Attempt to make a window key, supporting retries if necessary. The retries will be attempted
|
||||||
@ -339,15 +385,17 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
NSAnimationContext.runAnimationGroup({ context in
|
NSAnimationContext.runAnimationGroup(
|
||||||
context.duration = derivedConfig.quickTerminalAnimationDuration
|
{ context in
|
||||||
context.timingFunction = .init(name: .easeIn)
|
context.duration = derivedConfig.quickTerminalAnimationDuration
|
||||||
position.setInitial(in: window.animator(), on: screen)
|
context.timingFunction = .init(name: .easeIn)
|
||||||
}, completionHandler: {
|
position.setInitial(in: window.animator(), on: screen)
|
||||||
// This causes the window to be removed from the screen list and macOS
|
},
|
||||||
// handles what should be focused next.
|
completionHandler: {
|
||||||
window.orderOut(self)
|
// This causes the window to be removed from the screen list and macOS
|
||||||
})
|
// handles what should be focused next.
|
||||||
|
window.orderOut(self)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private func syncAppearance() {
|
private func syncAppearance() {
|
||||||
@ -364,7 +412,7 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
// to "native" which is typically P3. There is a lot more resources
|
// to "native" which is typically P3. There is a lot more resources
|
||||||
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
|
// covered in this GitHub issue: https://github.com/mitchellh/ghostty/pull/376
|
||||||
// Ghostty defaults to sRGB but this can be overridden.
|
// Ghostty defaults to sRGB but this can be overridden.
|
||||||
switch (self.derivedConfig.windowColorspace) {
|
switch self.derivedConfig.windowColorspace {
|
||||||
case "display-p3":
|
case "display-p3":
|
||||||
window.colorSpace = .displayP3
|
window.colorSpace = .displayP3
|
||||||
case "srgb":
|
case "srgb":
|
||||||
@ -374,7 +422,7 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If we have window transparency then set it transparent. Otherwise set it opaque.
|
// If we have window transparency then set it transparent. Otherwise set it opaque.
|
||||||
if (self.derivedConfig.backgroundOpacity < 1) {
|
if self.derivedConfig.backgroundOpacity < 1 {
|
||||||
window.isOpaque = false
|
window.isOpaque = false
|
||||||
|
|
||||||
// This is weird, but we don't use ".clear" because this creates a look that
|
// This is weird, but we don't use ".clear" because this creates a look that
|
||||||
@ -389,21 +437,24 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: First Responder
|
func updateSurfaceTree(to newTree: Ghostty.SplitNode) {
|
||||||
|
self.surfaceTree = newTree
|
||||||
@IBAction override func closeWindow(_ sender: Any) {
|
if case let .leaf(leaf) = newTree {
|
||||||
// Instead of closing the window, we animate it out.
|
self.focusedSurface = leaf.surface
|
||||||
animateOut()
|
guard let window = self.window, self.focusedSurface?.window == window else {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(25)) {
|
||||||
|
self.updateSurfaceTree(to: newTree)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
makeWindowKey(window, retries: 10)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: First Responder
|
||||||
|
|
||||||
@IBAction func newTab(_ sender: Any?) {
|
@IBAction func newTab(_ sender: Any?) {
|
||||||
guard let window else { return }
|
tabManager.addNewTab()
|
||||||
let alert = NSAlert()
|
|
||||||
alert.messageText = "Cannot Create New Tab"
|
|
||||||
alert.informativeText = "Tabs aren't supported in the Quick Terminal."
|
|
||||||
alert.addButton(withTitle: "OK")
|
|
||||||
alert.alertStyle = .warning
|
|
||||||
alert.beginSheetModal(for: window)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@IBAction func toggleGhosttyFullScreen(_ sender: Any) {
|
@IBAction func toggleGhosttyFullScreen(_ sender: Any) {
|
||||||
@ -427,9 +478,11 @@ class QuickTerminalController: BaseTerminalController {
|
|||||||
guard notification.object == nil else { return }
|
guard notification.object == nil else { return }
|
||||||
|
|
||||||
// Get our managed configuration object out
|
// Get our managed configuration object out
|
||||||
guard let config = notification.userInfo?[
|
guard
|
||||||
Notification.Name.GhosttyConfigChangeKey
|
let config = notification.userInfo?[
|
||||||
] as? Ghostty.Config else { return }
|
Notification.Name.GhosttyConfigChangeKey
|
||||||
|
] as? Ghostty.Config
|
||||||
|
else { return }
|
||||||
|
|
||||||
// Update our derived config
|
// Update our derived config
|
||||||
self.derivedConfig = DerivedConfig(config)
|
self.derivedConfig = DerivedConfig(config)
|
||||||
|
@ -0,0 +1,27 @@
|
|||||||
|
import Combine
|
||||||
|
|
||||||
|
class QuickTerminalTab: ObservableObject, Identifiable {
|
||||||
|
let id = UUID()
|
||||||
|
var surface: Ghostty.SplitNode
|
||||||
|
@Published var title: String
|
||||||
|
@Published var isActive: Bool = false
|
||||||
|
|
||||||
|
private var cancellable: AnyCancellable?
|
||||||
|
|
||||||
|
init(surface: Ghostty.SplitNode, title: String = "Terminal") {
|
||||||
|
self.surface = surface
|
||||||
|
self.title = surface.first { $0.surface.focused }?.surface.pwd ?? "Terminal"
|
||||||
|
|
||||||
|
let targetSurface = surface.first { $0.surface.focused }?.surface ?? surface.preferredFocus()
|
||||||
|
self.cancellable = targetSurface.$title
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] newTitle in
|
||||||
|
self?.title = newTitle
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
cancellable?.cancel()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,79 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TabBarView: View {
|
||||||
|
@ObservedObject var tabManager: QuickTerminalTabManager
|
||||||
|
@GestureState private var isDragging: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ScrollView(.horizontal, showsIndicators: false) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
ForEach(tabManager.tabs) { tab in
|
||||||
|
TabItemView(
|
||||||
|
tab: tab,
|
||||||
|
isSelected: tab.isActive,
|
||||||
|
onSelect: { tabManager.selectTab(tab) },
|
||||||
|
onClose: { tabManager.closeTab(tab) }
|
||||||
|
)
|
||||||
|
.onDrag {
|
||||||
|
tabManager.draggedTab = tab
|
||||||
|
return NSItemProvider(object: tab.id.uuidString as NSString)
|
||||||
|
}
|
||||||
|
.onDrop(
|
||||||
|
of: [.text],
|
||||||
|
delegate: TabDropDelegate(
|
||||||
|
item: tab,
|
||||||
|
tabManager: tabManager,
|
||||||
|
currentTab: tabManager.draggedTab
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.gesture(
|
||||||
|
DragGesture(minimumDistance: 0)
|
||||||
|
.updating($isDragging) { _, state, _ in
|
||||||
|
state = true
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
Image(systemName: "plus")
|
||||||
|
.foregroundColor(.gray)
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.frame(width: 50)
|
||||||
|
.contentShape(Rectangle()) // Make the entire frame clickable
|
||||||
|
.onTapGesture {
|
||||||
|
tabManager.addNewTab()
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.help("New Tab")
|
||||||
|
}
|
||||||
|
.frame(height: 32)
|
||||||
|
.background(Color(NSColor.windowBackgroundColor))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TabDropDelegate: DropDelegate {
|
||||||
|
let item: QuickTerminalTab
|
||||||
|
let tabManager: QuickTerminalTabManager
|
||||||
|
let currentTab: QuickTerminalTab?
|
||||||
|
|
||||||
|
func performDrop(info: DropInfo) -> Bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dropEntered(info: DropInfo) {
|
||||||
|
guard let currentTab = currentTab,
|
||||||
|
let from = tabManager.tabs.firstIndex(where: { $0.id == currentTab.id }),
|
||||||
|
let to = tabManager.tabs.firstIndex(where: { $0.id == item.id })
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
if tabManager.tabs[to].id != currentTab.id {
|
||||||
|
tabManager.tabs.move(
|
||||||
|
fromOffsets: IndexSet(integer: from),
|
||||||
|
toOffset: to > from ? to + 1 : to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TabItemView: View {
|
||||||
|
@ObservedObject var tab: QuickTerminalTab
|
||||||
|
let isSelected: Bool
|
||||||
|
let onSelect: () -> Void
|
||||||
|
let onClose: () -> Void
|
||||||
|
|
||||||
|
@State private var isHovered = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 4) {
|
||||||
|
Text(tab.title)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.frame(maxWidth: 150)
|
||||||
|
|
||||||
|
Button(action: onClose) {
|
||||||
|
Image(systemName: "xmark")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundColor(isHovered ? .primary : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(PlainButtonStyle())
|
||||||
|
.opacity(isHovered || isSelected ? 1 : 0)
|
||||||
|
.animation(.easeInOut, value: isHovered || isSelected)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 8)
|
||||||
|
.frame(height: 28)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 6)
|
||||||
|
.fill(
|
||||||
|
isSelected
|
||||||
|
? Color(NSColor.selectedContentBackgroundColor)
|
||||||
|
: (isHovered ? Color(NSColor.gridColor) : Color.clear))
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 2)
|
||||||
|
.onHover { hovering in
|
||||||
|
isHovered = hovering
|
||||||
|
}
|
||||||
|
.onTapGesture(
|
||||||
|
perform: {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
onSelect()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,127 @@
|
|||||||
|
import GhosttyKit
|
||||||
|
|
||||||
|
class QuickTerminalTabManager: ObservableObject {
|
||||||
|
@Published var tabs: [QuickTerminalTab] = []
|
||||||
|
@Published var currentTab: QuickTerminalTab?
|
||||||
|
@Published var draggedTab: QuickTerminalTab?
|
||||||
|
|
||||||
|
private weak var controller: QuickTerminalController?
|
||||||
|
|
||||||
|
init(controller: QuickTerminalController) {
|
||||||
|
self.controller = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func addNewTab() {
|
||||||
|
guard let ghostty = controller?.ghostty else { return }
|
||||||
|
|
||||||
|
let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil)
|
||||||
|
let surface: Ghostty.SplitNode = .leaf(leaf)
|
||||||
|
let tabIndex = tabs.count + 1
|
||||||
|
|
||||||
|
let newTab = QuickTerminalTab(surface: surface, title: "Terminal \(tabIndex)")
|
||||||
|
tabs.append(newTab)
|
||||||
|
|
||||||
|
selectTab(newTab)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectTab(_ tab: QuickTerminalTab) {
|
||||||
|
guard currentTab?.id != tab.id else { return } // Avoid unnecessary updates
|
||||||
|
|
||||||
|
currentTab?.isActive = false
|
||||||
|
tab.isActive = true
|
||||||
|
currentTab = tab
|
||||||
|
|
||||||
|
controller?.updateSurfaceTree(to: tab.surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeTab(_ tab: QuickTerminalTab) {
|
||||||
|
if let index = tabs.firstIndex(where: { $0.id == tab.id }) {
|
||||||
|
tabs.remove(at: index)
|
||||||
|
|
||||||
|
if currentTab?.id == tab.id {
|
||||||
|
if tabs.isEmpty {
|
||||||
|
addNewTab()
|
||||||
|
} else {
|
||||||
|
let newIndex = min(index, tabs.count - 1)
|
||||||
|
selectTab(tabs[newIndex])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveTab(from source: IndexSet, to destination: Int) {
|
||||||
|
tabs.move(fromOffsets: source, toOffset: destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectNextTab() {
|
||||||
|
guard let currentTab = currentTab,
|
||||||
|
let currentIndex = tabs.firstIndex(where: { $0.id == currentTab.id })
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let nextIndex = (currentIndex + 1) % tabs.count
|
||||||
|
selectTab(tabs[nextIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPreviousTab() {
|
||||||
|
guard let currentTab = currentTab,
|
||||||
|
let currentIndex = tabs.firstIndex(where: { $0.id == currentTab.id })
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let previousIndex = (currentIndex - 1 + tabs.count) % tabs.count
|
||||||
|
selectTab(tabs[previousIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
//MARK: - Notifications
|
||||||
|
|
||||||
|
@objc func onMoveTab(_ notification: Notification) {
|
||||||
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||||
|
guard target == controller?.focusedSurface else { return }
|
||||||
|
|
||||||
|
// Get the move action
|
||||||
|
guard
|
||||||
|
let action = notification.userInfo?[Notification.Name.GhosttyMoveTabKey]
|
||||||
|
as? Ghostty.Action.MoveTab
|
||||||
|
else { return }
|
||||||
|
guard action.amount != 0 else { return }
|
||||||
|
|
||||||
|
guard let currentTabIndex = tabs.firstIndex(where: { $0.id == currentTab?.id }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the final index we want to insert our tab
|
||||||
|
let finalIndex: Int
|
||||||
|
if action.amount < 0 {
|
||||||
|
finalIndex = max(0, currentTabIndex - min(currentTabIndex, -action.amount))
|
||||||
|
} else {
|
||||||
|
let remaining: Int = tabs.count - 1 - currentTabIndex
|
||||||
|
finalIndex = currentTabIndex + min(remaining, action.amount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If our index is the same we do nothing
|
||||||
|
guard finalIndex != currentTabIndex else { return }
|
||||||
|
|
||||||
|
// move the tab
|
||||||
|
moveTab(from: IndexSet(integer: currentTabIndex), to: finalIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func onGoToTab(_ notification: Notification) {
|
||||||
|
guard let target = notification.object as? Ghostty.SurfaceView else { return }
|
||||||
|
guard target == controller?.focusedSurface else { return }
|
||||||
|
|
||||||
|
guard let tabEnumAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let tabEnum = tabEnumAny as? ghostty_action_goto_tab_e else { return }
|
||||||
|
let tabIndex: Int32 = tabEnum.rawValue
|
||||||
|
|
||||||
|
if tabIndex == GHOSTTY_GOTO_TAB_PREVIOUS.rawValue {
|
||||||
|
selectPreviousTab()
|
||||||
|
} else if tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue {
|
||||||
|
selectNextTab()
|
||||||
|
} else if tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue {
|
||||||
|
selectTab(tabs[tabs.count - 1])
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user