macos: tahoe titlebar tabs taking shape
parent
6ae8bd737a
commit
5f99670247
|
|
@ -18,8 +18,8 @@ class TerminalController: BaseTerminalController {
|
||||||
case "tabs":
|
case "tabs":
|
||||||
if #available(macOS 26.0, *) {
|
if #available(macOS 26.0, *) {
|
||||||
// TODO: Switch to Tahoe when ready
|
// TODO: Switch to Tahoe when ready
|
||||||
"TerminalTabsTitlebarVentura"
|
//"TerminalTabsTitlebarVentura"
|
||||||
//"TerminalTabsTitlebarTahoe"
|
"TerminalTabsTitlebarTahoe"
|
||||||
} else {
|
} else {
|
||||||
"TerminalTabsTitlebarVentura"
|
"TerminalTabsTitlebarVentura"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,20 +110,50 @@ class TerminalWindow: NSWindow {
|
||||||
// Tab bar is attached as a titlebar accessory view controller (layout bottom). We
|
// Tab bar is attached as a titlebar accessory view controller (layout bottom). We
|
||||||
// can detect when it is shown or hidden by overriding add/remove and searching for
|
// can detect when it is shown or hidden by overriding add/remove and searching for
|
||||||
// it. This has been verified to work on macOS 12 to 26
|
// it. This has been verified to work on macOS 12 to 26
|
||||||
if childViewController.view.contains(className: "NSTabBar") {
|
if isTabBar(childViewController) {
|
||||||
|
childViewController.identifier = Self.tabBarIdentifier
|
||||||
viewModel.hasTabBar = true
|
viewModel.hasTabBar = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func removeTitlebarAccessoryViewController(at index: Int) {
|
override func removeTitlebarAccessoryViewController(at index: Int) {
|
||||||
if let childViewController = titlebarAccessoryViewControllers[safe: index],
|
if let childViewController = titlebarAccessoryViewControllers[safe: index], isTabBar(childViewController) {
|
||||||
childViewController.view.contains(className: "NSTabBar") {
|
|
||||||
viewModel.hasTabBar = false
|
viewModel.hasTabBar = false
|
||||||
}
|
}
|
||||||
|
|
||||||
super.removeTitlebarAccessoryViewController(at: index)
|
super.removeTitlebarAccessoryViewController(at: index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: Tab Bar
|
||||||
|
|
||||||
|
/// This identifier is attached to the tab bar view controller when we detect it being
|
||||||
|
/// added.
|
||||||
|
private static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
|
||||||
|
|
||||||
|
func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool {
|
||||||
|
if childViewController.identifier == nil {
|
||||||
|
// The good case
|
||||||
|
if childViewController.view.contains(className: "NSTabBar") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// When a new window is attached to an existing tab group, AppKit adds
|
||||||
|
// an empty NSView as an accessory view and adds the tab bar later. If
|
||||||
|
// we're at the bottom and are a single NSView we assume its a tab bar.
|
||||||
|
if childViewController.layoutAttribute == .bottom &&
|
||||||
|
childViewController.view.className == "NSView" &&
|
||||||
|
childViewController.view.subviews.isEmpty {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// View controllers should be tagged with this as soon as possible to
|
||||||
|
// increase our accuracy. We do this manually.
|
||||||
|
return childViewController.identifier == Self.tabBarIdentifier
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: Tab Key Equivalents
|
// MARK: Tab Key Equivalents
|
||||||
|
|
||||||
var keyEquivalent: String? = nil {
|
var keyEquivalent: String? = nil {
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate {
|
||||||
self.toolbar = toolbar
|
self.toolbar = toolbar
|
||||||
toolbarStyle = .unifiedCompact
|
toolbarStyle = .unifiedCompact
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: NSWindow
|
// MARK: NSWindow
|
||||||
|
|
||||||
override var title: String {
|
override var title: String {
|
||||||
|
|
@ -29,13 +28,94 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override func update() {
|
override var toolbar: NSToolbar? {
|
||||||
super.update()
|
didSet{
|
||||||
|
guard toolbar != nil else { return }
|
||||||
|
|
||||||
|
// When a toolbar is added, remove the Liquid Glass look because we're
|
||||||
|
// abusing the toolbar as a tab bar.
|
||||||
if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") {
|
if let glass = titlebarContainer?.firstDescendant(withClassName: "NSGlassContainerView") {
|
||||||
glass.isHidden = true
|
glass.isHidden = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func becomeMain() {
|
||||||
|
super.becomeMain()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) {
|
||||||
|
self.contentView?.printViewHierarchy()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is called by macOS for native tabbing in order to add the tab bar. We hook into
|
||||||
|
// this, detect the tab bar being added, and override its behavior.
|
||||||
|
override func addTitlebarAccessoryViewController(_ childViewController: NSTitlebarAccessoryViewController) {
|
||||||
|
// If this is the tab bar then we need to set it up for the titlebar
|
||||||
|
guard isTabBar(childViewController) else {
|
||||||
|
super.addTitlebarAccessoryViewController(childViewController)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some setup needs to happen BEFORE it is added, such as layout. If
|
||||||
|
// we don't do this before the call below, we'll trigger an AppKit
|
||||||
|
// assertion.
|
||||||
|
childViewController.layoutAttribute = .right
|
||||||
|
|
||||||
|
super.addTitlebarAccessoryViewController(childViewController)
|
||||||
|
|
||||||
|
// View model updates must happen on their own ticks
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.viewModel.hasTabBar = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup the tab bar to go into the titlebar.
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// HACK: wait a tick before doing anything, to avoid edge cases during startup... :/
|
||||||
|
// If we don't do this then on launch windows with restored state with tabs will end
|
||||||
|
// up with messed up tab bars that don't show all tabs.
|
||||||
|
let accessoryView = childViewController.view
|
||||||
|
guard let clipView = accessoryView.firstSuperview(withClassName: "NSTitlebarAccessoryClipView") else { return }
|
||||||
|
guard let titlebarView = clipView.firstSuperview(withClassName: "NSTitlebarView") else { return }
|
||||||
|
guard let toolbarView = titlebarView.firstDescendant(withClassName: "NSToolbarView") else { return }
|
||||||
|
|
||||||
|
// The container is the view that we'll constrain our tab bar within.
|
||||||
|
let container = toolbarView
|
||||||
|
|
||||||
|
// Constrain the accessory clip view (the parent of the accessory view
|
||||||
|
// usually that clips the children) to the container view.
|
||||||
|
clipView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
clipView.leftAnchor.constraint(equalTo: container.leftAnchor, constant: 78).isActive = true
|
||||||
|
clipView.rightAnchor.constraint(equalTo: container.rightAnchor).isActive = true
|
||||||
|
clipView.topAnchor.constraint(equalTo: container.topAnchor).isActive = true
|
||||||
|
clipView.heightAnchor.constraint(equalTo: container.heightAnchor).isActive = true
|
||||||
|
clipView.needsLayout = true
|
||||||
|
|
||||||
|
// Constrain the actual accessory view (the tab bar) to the clip view
|
||||||
|
// so it takes up the full space.
|
||||||
|
accessoryView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
accessoryView.leftAnchor.constraint(equalTo: clipView.leftAnchor).isActive = true
|
||||||
|
accessoryView.rightAnchor.constraint(equalTo: clipView.rightAnchor).isActive = true
|
||||||
|
accessoryView.topAnchor.constraint(equalTo: clipView.topAnchor).isActive = true
|
||||||
|
accessoryView.heightAnchor.constraint(equalTo: clipView.heightAnchor).isActive = true
|
||||||
|
accessoryView.needsLayout = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func removeTitlebarAccessoryViewController(at index: Int) {
|
||||||
|
guard let childViewController = titlebarAccessoryViewControllers[safe: index],
|
||||||
|
isTabBar(childViewController) else {
|
||||||
|
super.removeTitlebarAccessoryViewController(at: index)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
super.removeTitlebarAccessoryViewController(at: index)
|
||||||
|
|
||||||
|
// View model needs to be updated on another tick because it
|
||||||
|
// triggers view updates.
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.viewModel.hasTabBar = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: NSToolbarDelegate
|
// MARK: NSToolbarDelegate
|
||||||
|
|
||||||
|
|
@ -66,6 +146,7 @@ class TitlebarTabsTahoeTerminalWindow: TerminalWindow, NSToolbarDelegate {
|
||||||
|
|
||||||
class ViewModel: ObservableObject {
|
class ViewModel: ObservableObject {
|
||||||
@Published var title: String = "👻 Ghostty"
|
@Published var title: String = "👻 Ghostty"
|
||||||
|
@Published var hasTabBar: Bool = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,13 +164,20 @@ extension TitlebarTabsTahoeTerminalWindow {
|
||||||
// An empty title makes this view zero-sized and NSToolbar on macOS
|
// An empty title makes this view zero-sized and NSToolbar on macOS
|
||||||
// tahoe just deletes the item when that happens. So we use a space
|
// tahoe just deletes the item when that happens. So we use a space
|
||||||
// instead to ensure there's always some size.
|
// instead to ensure there's always some size.
|
||||||
viewModel.title.isEmpty ? " " : viewModel.title
|
return viewModel.title.isEmpty ? " " : viewModel.title
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
if !viewModel.hasTabBar {
|
||||||
Text(title)
|
Text(title)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
|
} else {
|
||||||
|
// 1x1.gif strikes again! For real: if we render a zero-sized
|
||||||
|
// view here then the toolbar just disappears our view. I don't
|
||||||
|
// know.
|
||||||
|
Color.clear.frame(width: 1, height: 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,16 @@ extension NSView {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Finds the superview with the given class name.
|
||||||
|
func firstSuperview(withClassName name: String) -> NSView? {
|
||||||
|
guard let superview else { return nil }
|
||||||
|
if String(describing: type(of: superview)) == name {
|
||||||
|
return superview
|
||||||
|
}
|
||||||
|
|
||||||
|
return superview.firstSuperview(withClassName: name)
|
||||||
|
}
|
||||||
|
|
||||||
/// 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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue