macos: native terminal style works with new subclasses

This commit is contained in:
Mitchell Hashimoto
2025-06-11 14:35:49 -07:00
parent dfa7a114de
commit a804dab288
7 changed files with 151 additions and 22 deletions

View File

@@ -402,12 +402,12 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A59630992AEE1C6400D64628 /* Terminal.xib */, A59630992AEE1C6400D64628 /* Terminal.xib */,
A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */,
A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */, A5593FE22DF8D78600B47B10 /* TerminalHiddenTitlebar.xib */,
A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */,
A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */, A5593FE42DF8DE3000B47B10 /* TerminalLegacy.xib */,
A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */,
A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */, A5593FE82DF927DF00B47B10 /* TerminalTransparentTitlebar.xib */,
A5593FDE2DF8D57100B47B10 /* TerminalWindow.swift */,
A5593FE02DF8D73400B47B10 /* HiddenTitlebarTerminalWindow.swift */,
A51B78462AF4B58B00F3EDB9 /* LegacyTerminalWindow.swift */,
A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */, A5593FE62DF927CC00B47B10 /* TransparentTitlebarTerminalWindow.swift */,
); );
path = "Window Styles"; path = "Window Styles";

View File

@@ -569,6 +569,10 @@ class BaseTerminalController: NSWindowController,
surfaceTree = SplitTree(root: surfaceTree.root, zoomed: targetNode) 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 // Ensure focus stays on the target surface. We lose focus when we do
// this so we need to grab it again. // this so we need to grab it again.
DispatchQueue.main.async { DispatchQueue.main.async {

View File

@@ -14,6 +14,7 @@ class TerminalController: BaseTerminalController {
guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue } guard let appDelegate = NSApp.delegate as? AppDelegate else { return defaultValue }
let config = appDelegate.ghostty.config let config = appDelegate.ghostty.config
let nib = switch config.macosTitlebarStyle { let nib = switch config.macosTitlebarStyle {
case "native": "Terminal"
case "tabs": defaultValue case "tabs": defaultValue
case "hidden": "TerminalHiddenTitlebar" case "hidden": "TerminalHiddenTitlebar"
case "transparent": "TerminalTransparentTitlebar" case "transparent": "TerminalTransparentTitlebar"
@@ -132,6 +133,9 @@ class TerminalController: BaseTerminalController {
if let window = window as? LegacyTerminalWindow { if let window = window as? LegacyTerminalWindow {
window.surfaceIsZoomed = to.zoomed != nil 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 our surface tree is now nil then we close our window.
if (to.isEmpty) { if (to.isEmpty) {
@@ -395,28 +399,44 @@ class TerminalController: BaseTerminalController {
/// changes, when a window is closed, and when tabs are reordered /// changes, when a window is closed, and when tabs are reordered
/// with the mouse. /// with the mouse.
func relabelTabs() { 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, // We only listen for frame changes if we have more than 1 window,
// otherwise the accessory view doesn't matter. // otherwise the accessory view doesn't matter.
tabListenForFrame = windows.count > 1 tabListenForFrame = window?.tabbedWindows?.count ?? 0 > 1
for (tab, window) in zip(1..., windows) { if let windows = window?.tabbedWindows as? [TerminalWindow] {
// We need to clear any windows beyond this because they have had for (tab, window) in zip(1..., windows) {
// a keyEquivalent set previously. // We need to clear any windows beyond this because they have had
guard tab <= 9 else { // a keyEquivalent set previously.
window.keyEquivalent = "" guard tab <= 9 else {
continue 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)" // Legacy
if let equiv = ghostty.config.keyboardShortcut(for: action) { if let windows = self.window?.tabbedWindows as? [LegacyTerminalWindow] {
window.keyEquivalent = "\(equiv)" for (tab, window) in zip(1..., windows) {
} else { // We need to clear any windows beyond this because they have had
window.keyEquivalent = "" // 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 = ""
}
} }
} }
} }

View File

@@ -17,7 +17,7 @@
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/> <windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/> <windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/> <rect key="contentRect" x="0.0" y="0.0" width="800" height="600"/>
<rect key="screenRect" x="0.0" y="0.0" width="1512" height="948"/> <rect key="screenRect" x="0.0" y="0.0" width="3008" height="1661"/>
<view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ"> <view key="contentView" wantsLayer="YES" id="EiT-Mj-1SZ">
<rect key="frame" x="0.0" y="0.0" width="800" height="600"/> <rect key="frame" x="0.0" y="0.0" width="800" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>

View File

@@ -1,4 +1,5 @@
import AppKit import AppKit
import SwiftUI
import GhosttyKit import GhosttyKit
/// The base class for all standalone, "normal" terminal windows. This sets the basic /// The base class for all standalone, "normal" terminal windows. This sets the basic
@@ -42,6 +43,14 @@ class TerminalWindow: NSWindow {
hideWindowButtons() 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 // Get our saved level
level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal 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 canBecomeKey: Bool { return true }
override var canBecomeMain: 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 // MARK: Positioning And Styling
/// This is called by the controller when there is a need to reset the window apperance. /// This is called by the controller when there is a need to reset the window apperance.

View File

@@ -26,6 +26,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow {
} }
override func becomeMain() { override func becomeMain() {
super.becomeMain()
guard let lastSurfaceConfig else { return } guard let lastSurfaceConfig else { return }
syncAppearance(lastSurfaceConfig) syncAppearance(lastSurfaceConfig)

View File

@@ -27,6 +27,21 @@ extension NSView {
return root 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. /// Recursively finds and returns the first descendant view that has the given class name.
func firstDescendant(withClassName name: String) -> NSView? { func firstDescendant(withClassName name: String) -> NSView? {
for subview in subviews { for subview in subviews {