diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj
index c00d6119a..5f5b3013c 100644
--- a/macos/Ghostty.xcodeproj/project.pbxproj
+++ b/macos/Ghostty.xcodeproj/project.pbxproj
@@ -402,12 +402,12 @@
isa = PBXGroup;
children = (
A59630992AEE1C6400D64628 /* Terminal.xib */,
- A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */,
A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */,
- A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */,
A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */,
- A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */,
A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */,
+ A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */,
+ A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */,
+ A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */,
A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */,
);
path = "Window Styles";
diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift
index e91199358..849f13b34 100644
--- a/macos/Sources/Features/Terminal/BaseTerminalController.swift
+++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift
@@ -568,7 +568,11 @@ class BaseTerminalController: NSWindowController,
// 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 {
diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift
index 5adef8ded..082a3c806 100644
--- a/macos/Sources/Features/Terminal/TerminalController.swift
+++ b/macos/Sources/Features/Terminal/TerminalController.swift
@@ -14,6 +14,7 @@ class TerminalController: BaseTerminalController {
guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue }
let config = appDelegate.ghostty.config
let nib = switch config.macosTitlebarStyle {
+ case "native": "Terminal"
case "tabs": defaultValue
case "hidden": "TerminalHiddenTitlebar"
case "transparent": "TerminalTransparentTitlebar"
@@ -132,6 +133,9 @@ class TerminalController: BaseTerminalController {
if let window = window as? LegacyTerminalWindow {
window.surfaceIsZoomed = to.zoomed != nil
}
+ if let window = window as? TerminalWindow {
+ window.surfaceIsZoomed2 = to.zoomed != nil
+ }
// If our surface tree is now nil then we close our window.
if (to.isEmpty) {
@@ -395,28 +399,44 @@ class TerminalController: BaseTerminalController {
/// changes, when a window is closed, and when tabs are reordered
/// with the mouse.
func relabelTabs() {
- // Reset this to false. It'll be set back to true later.
- tabListenForFrame = false
-
- guard let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] else { return }
-
// We only listen for frame changes if we have more than 1 window,
// otherwise the accessory view doesn't matter.
- tabListenForFrame = windows.count > 1
+ tabListenForFrame = window?.tabbedWindows?.count ?? 0 > 1
- for (tab, window) in zip(1..., windows) {
- // We need to clear any windows beyond this because they have had
- // a keyEquivalent set previously.
- guard tab <= 9 else {
- window.keyEquivalent = ""
- continue
+ if let windows = window?.tabbedWindows as? [TerminalWindow] {
+ for (tab, window) in zip(1..., windows) {
+ // We need to clear any windows beyond this because they have had
+ // a keyEquivalent set previously.
+ guard tab <= 9 else {
+ window.keyEquivalent2 = ""
+ continue
+ }
+
+ let action = "goto_tab:\(tab)"
+ if let equiv = ghostty.config.keyboardShortcut(for: action) {
+ window.keyEquivalent2 = "\(equiv)"
+ } else {
+ window.keyEquivalent2 = ""
+ }
}
+ }
- let action = "goto_tab:\(tab)"
- if let equiv = ghostty.config.keyboardShortcut(for: action) {
- window.keyEquivalent = "\(equiv)"
- } else {
- window.keyEquivalent = ""
+ // Legacy
+ if let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] {
+ for (tab, window) in zip(1..., windows) {
+ // We need to clear any windows beyond this because they have had
+ // a keyEquivalent set previously.
+ guard tab <= 9 else {
+ window.keyEquivalent = ""
+ continue
+ }
+
+ let action = "goto_tab:\(tab)"
+ if let equiv = ghostty.config.keyboardShortcut(for: action) {
+ window.keyEquivalent = "\(equiv)"
+ } else {
+ window.keyEquivalent = ""
+ }
}
}
}
diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib
index ada6959b3..25922e2f3 100644
--- a/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib
+++ b/macos/Sources/Features/Terminal/Window Styles/TerminalTransparentTitlebar.xib
@@ -17,7 +17,7 @@
-
+
diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
index daf5b4554..9fac08c4b 100644
--- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
+++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift
@@ -1,4 +1,5 @@
import AppKit
+import SwiftUI
import GhosttyKit
/// The base class for all standalone, "normal" terminal windows. This sets the basic
@@ -42,6 +43,14 @@ class TerminalWindow: NSWindow {
hideWindowButtons()
}
+ // Setup the accessory view for tabs that shows our keyboard shortcuts,
+ // zoomed state, etc. Note I tried to use SwiftUI here but ran into issues
+ // where buttons were not clickable.
+ let stackView = NSStackView(views: [keyEquivalentLabel, resetZoomTabButton])
+ stackView.setHuggingPriority(.defaultHigh, for: .horizontal)
+ stackView.spacing = 3
+ tab.accessoryView = stackView
+
// Get our saved level
level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal
}
@@ -51,6 +60,85 @@ class TerminalWindow: NSWindow {
override var canBecomeKey: Bool { return true }
override var canBecomeMain: Bool { return true }
+ override func becomeKey() {
+ super.becomeKey()
+ resetZoomTabButton.contentTintColor = .controlAccentColor
+ }
+
+ override func resignKey() {
+ super.resignKey()
+ resetZoomTabButton.contentTintColor = .secondaryLabelColor
+ }
+
+ override func mergeAllWindows(_ sender: Any?) {
+ super.mergeAllWindows(sender)
+
+ // It takes an event loop cycle to merge all the windows so we set a
+ // short timer to relabel the tabs (issue #1902)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
+ self?.terminalController?.relabelTabs()
+ }
+ }
+
+ // MARK: Tab Key Equivalents
+
+ // TODO: rename once Legacy window removes
+ var keyEquivalent2: String? = nil {
+ didSet {
+ // When our key equivalent is set, we must update the tab label.
+ guard let keyEquivalent2 else {
+ keyEquivalentLabel.attributedStringValue = NSAttributedString()
+ return
+ }
+
+ keyEquivalentLabel.attributedStringValue = NSAttributedString(
+ string: "\(keyEquivalent2) ",
+ attributes: [
+ .font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize),
+ .foregroundColor: isKeyWindow ? NSColor.labelColor : NSColor.secondaryLabelColor,
+ ])
+ }
+ }
+
+ /// The label that has the key equivalent for tab views.
+ private lazy var keyEquivalentLabel: NSTextField = {
+ let label = NSTextField(labelWithAttributedString: NSAttributedString())
+ label.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal)
+ label.postsFrameChangedNotifications = true
+ return label
+ }()
+
+ // MARK: Surface Zoom
+
+ /// Set to true if a surface is currently zoomed to show the reset zoom button.
+ var surfaceIsZoomed2: Bool = false {
+ didSet {
+ // Show/hide our reset zoom button depending on if we're zoomed.
+ // We want to show it if we are zoomed.
+ resetZoomTabButton.isHidden = !surfaceIsZoomed2
+ }
+ }
+
+ private lazy var resetZoomTabButton: NSButton = generateResetZoomButton()
+
+ private func generateResetZoomButton() -> NSButton {
+ let button = NSButton()
+ button.isHidden = true
+ button.target = terminalController
+ button.action = #selector(TerminalController.splitZoom(_:))
+ button.isBordered = false
+ button.allowsExpansionToolTips = true
+ button.toolTip = "Reset Zoom"
+ button.contentTintColor = .controlAccentColor
+ button.state = .on
+ button.image = NSImage(named:"ResetZoom")
+ button.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.widthAnchor.constraint(equalToConstant: 20).isActive = true
+ button.heightAnchor.constraint(equalToConstant: 20).isActive = true
+ return button
+ }
+
// MARK: Positioning And Styling
/// This is called by the controller when there is a need to reset the window apperance.
diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift
index 98dd9f834..ada84ff12 100644
--- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift
+++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift
@@ -26,6 +26,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
}
override func becomeMain() {
+ super.becomeMain()
+
guard let lastSurfaceConfig else { return }
syncAppearance(lastSurfaceConfig)
diff --git a/macos/Sources/Helpers/Extensions/NSView+Extension.swift b/macos/Sources/Helpers/Extensions/NSView+Extension.swift
index 0cf71138d..aa56fe32e 100644
--- a/macos/Sources/Helpers/Extensions/NSView+Extension.swift
+++ b/macos/Sources/Helpers/Extensions/NSView+Extension.swift
@@ -27,6 +27,21 @@ extension NSView {
return root
}
+ /// Checks if a view contains another view in its hierarchy.
+ func contains(_ view: NSView) -> Bool {
+ if self == view {
+ return true
+ }
+
+ for subview in subviews {
+ if subview.contains(view) {
+ return true
+ }
+ }
+
+ return false
+ }
+
/// Recursively finds and returns the first descendant view that has the given class name.
func firstDescendant(withClassName name: String) -> NSView? {
for subview in subviews {