diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5bb44f341..38d68ef32 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -3,6 +3,31 @@ import Cocoa import SwiftUI import GhosttyKit +fileprivate class ZoomButtonView: NSView { + let target: Any + let action: Selector + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + init(frame frameRect: NSRect, target: Any, selector: Selector) { + self.target = target + self.action = selector + + super.init(frame: frameRect) + + let zoomButton = NSButton(image: NSImage(systemSymbolName: "arrow.down.right.and.arrow.up.left.square.fill", accessibilityDescription: nil)!, target: target, action: selector) + + zoomButton.frame = bounds + zoomButton.isBordered = false + zoomButton.contentTintColor = .systemBlue + zoomButton.state = .on + zoomButton.imageScaling = .scaleProportionallyUpOrDown + addSubview(zoomButton) + } +} + /// The terminal controller is an NSWindowController that maps 1:1 to a terminal window. class TerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate, TerminalViewModel, @@ -54,8 +79,10 @@ class TerminalController: NSWindowController, NSWindowDelegate, /// This is set to false by init if the window managed by this controller should not be restorable. /// For example, terminals executing custom scripts are not restorable. private var restorable: Bool = true - - init(_ ghostty: Ghostty.App, + + private var surfaceIsZoomed: Bool = false + + init(_ ghostty: Ghostty.App, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: Ghostty.SplitNode? = nil ) { @@ -120,13 +147,13 @@ class TerminalController: NSWindowController, NSWindowDelegate, func relabelTabs() { // Reset this to false. It'll be set back to true later. tabListenForFrame = false - + guard let windows = self.window?.tabbedWindows 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 - + for (index, window) in windows.enumerated().prefix(9) { let action = "goto_tab:\(index + 1)" guard let equiv = ghostty.config.keyEquivalent(for: action) else { @@ -141,10 +168,25 @@ class TerminalController: NSWindowController, NSWindowDelegate, let text = NSTextField(labelWithAttributedString: attributedString) text.setContentCompressionResistancePriority(.windowSizeStayPut, for: .horizontal) text.postsFrameChangedNotifications = true - window.tab.accessoryView = text + + let stackView = NSStackView(views: [text]) +// stackView.setHuggingPriority(.defaultHigh, for: .horizontal) + + window.tab.accessoryView = stackView + } + + if surfaceIsZoomed { + guard let stackView = window?.tabGroup?.selectedWindow?.tab.accessoryView as? NSStackView else { return } + + var zoomButton: ZoomButtonView = ZoomButtonView(frame: NSRect(x: 0, y: 0, width: 20, height: 20), target: self, selector: #selector(splitZoom(_:))) + + zoomButton.translatesAutoresizingMaskIntoConstraints = false + zoomButton.widthAnchor.constraint(equalToConstant: 20).isActive = true + zoomButton.heightAnchor.constraint(equalToConstant: 20).isActive = true + stackView.addArrangedSubview(zoomButton) } } - + private func fixTabBar() { // We do this to make sure that the tab bar will always re-composite. If we don't, // then the it will "drag" pieces of the background with it when a transparent @@ -200,7 +242,17 @@ class TerminalController: NSWindowController, NSWindowDelegate, leaf.surface.focusDidChange(focused) } } - + + func windowDidUpdate(_ notification: Notification) { + updateToolbarZoomButton() + } + + private func updateToolbarZoomButton() { + guard let itemView = window?.toolbar?.items.last?.view as? ZoomButtonView else { return } + + itemView.alphaValue = surfaceIsZoomed ? 1 : 0 + } + //MARK: - NSWindowController override func windowWillLoad() { @@ -305,6 +357,11 @@ class TerminalController: NSWindowController, NSWindowDelegate, window.tabGroup?.removeWindow(window) } } + + guard let toolbarZoomItem = window.toolbar?.items.last else { return } + var zoomButton: ZoomButtonView = ZoomButtonView(frame: NSRect(x: 0, y: 0, width: 20, height: 20), target: self, selector: #selector(splitZoom(_:))) + + toolbarZoomItem.view = zoomButton } // Shows the "+" button in the tab bar, responds to that click. @@ -594,7 +651,13 @@ class TerminalController: NSWindowController, NSWindowDelegate, // we want to invalidate our state. invalidateRestorableState() } - + + func zoomStateDidChange(to: Bool) { + self.surfaceIsZoomed = to + updateToolbarZoomButton() + relabelTabs() + } + //MARK: - Clipboard Confirmation func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) { diff --git a/macos/Sources/Features/Terminal/TerminalToolbar.swift b/macos/Sources/Features/Terminal/TerminalToolbar.swift index b0857cb24..0bc389385 100644 --- a/macos/Sources/Features/Terminal/TerminalToolbar.swift +++ b/macos/Sources/Features/Terminal/TerminalToolbar.swift @@ -1,5 +1,9 @@ import Cocoa +fileprivate extension NSToolbarItem.Identifier { + static let zoom = NSToolbarItem.Identifier("zoom") +} + // Custom NSToolbar subclass that displays a centered window title, // in order to accommodate the titlebar tabs feature. class TerminalToolbar: NSToolbar, NSToolbarDelegate { @@ -31,32 +35,37 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { - guard itemIdentifier == Self.identifier else { - return NSToolbarItem(itemIdentifier: itemIdentifier) + var item: NSToolbarItem + + switch itemIdentifier { + case Self.identifier: + item = NSToolbarItem(itemIdentifier: itemIdentifier) + item.view = self.titleTextField + item.visibilityPriority = .user + + // NSToolbarItem.minSize and NSToolbarItem.maxSize are deprecated, and make big ugly + // warnings in Xcode when you use them, but I cannot for the life of me figure out + // how to get this to work with constraints. The behavior isn't the same, instead of + // shrinking the item and clipping the subview, it hides the item as soon as the + // intrinsic size of the subview gets too big for the toolbar width, regardless of + // whether I have constraints set on its width, height, or both :/ + // + // If someone can fix this so we don't have to use deprecated properties: Please do. + item.minSize = NSSize(width: 32, height: 1) + item.maxSize = NSSize(width: 1024, height: self.titleTextField.intrinsicContentSize.height) + + item.isEnabled = true + case .zoom: + item = NSToolbarItem(itemIdentifier: NSToolbarItem.Identifier("zoom")) + default: + item = NSToolbarItem(itemIdentifier: itemIdentifier) } - - let toolbarItem = NSToolbarItem(itemIdentifier: itemIdentifier) - toolbarItem.view = self.titleTextField - toolbarItem.visibilityPriority = .user - - // NSToolbarItem.minSize and NSToolbarItem.maxSize are deprecated, and make big ugly - // warnings in Xcode when you use them, but I cannot for the life of me figure out - // how to get this to work with constraints. The behavior isn't the same, instead of - // shrinking the item and clipping the subview, it hides the item as soon as the - // intrinsic size of the subview gets too big for the toolbar width, regardless of - // whether I have constraints set on its width, height, or both :/ - // - // If someone can fix this so we don't have to use deprecated properties: Please do. - toolbarItem.minSize = NSSize(width: 32, height: 1) - toolbarItem.maxSize = NSSize(width: 1024, height: self.titleTextField.intrinsicContentSize.height) - - toolbarItem.isEnabled = true - - return toolbarItem + + return item } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - return [Self.identifier, .space] + return [Self.identifier, .space, .zoom] } func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { @@ -64,7 +73,7 @@ class TerminalToolbar: NSToolbar, NSToolbarDelegate { // getting smaller than the max size so starts clipping. Lucky for us, three of the // built-in spacers seems to exactly match the space on the left that's reserved for // the window buttons. - return [Self.identifier, .space, .space, .space] + return [Self.identifier, .space, .space, .zoom] } } diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index d0766c7ab..8e1f0dbdd 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -17,6 +17,8 @@ protocol TerminalViewDelegate: AnyObject { /// The surface tree did change in some way, i.e. a split was added, removed, etc. This is /// not called initially. func surfaceTreeDidChange() + + func zoomStateDidChange(to: Bool) } // Default all the functions so they're optional @@ -24,6 +26,7 @@ extension TerminalViewDelegate { func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {} func titleDidChange(to: String) {} func cellSizeDidChange(to: NSSize) {} + func zoomStateDidChange(to: Bool) {} } /// The view model is a required implementation for TerminalView callers. This contains @@ -64,12 +67,6 @@ struct TerminalView: View { } } - if let zoomedSplit = zoomedSplit { - if zoomedSplit { - title = "🔍 " + title - } - } - return title } @@ -107,6 +104,9 @@ struct TerminalView: View { // in the hash value. self.delegate?.surfaceTreeDidChange() } + .onChange(of: zoomedSplit) { newValue in + self.delegate?.zoomStateDidChange(to: newValue ?? false) + } } } }