macOS: move window title handling fully to AppKit

Fixes #7236
Supersedes #7249

This removes all of our `focusedValue`-based tracking of the surface
title and moves it completely to the window controller. The window
controller now sets up event listeners (via Combine) when the focused
surface changes and updates the window title accordingly.

There is some complicated logic here to handle when we lose focus to
something other than a surface. In this case, we want our title to be
the last focused surface so long as it exists.
This commit is contained in:
Mitchell Hashimoto
2025-05-06 14:54:59 -07:00
parent 27cdd6d79c
commit 2caa8a3fe1
5 changed files with 23 additions and 31 deletions

View File

@ -1,5 +1,6 @@
import Cocoa
import SwiftUI
import Combine
import GhosttyKit
/// A base class for windows that can contain Ghostty windows. This base class implements
@ -71,6 +72,9 @@ class BaseTerminalController: NSWindowController,
/// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig
/// The cancellables related to our focused surface.
private var focusedSurfaceCancellables: Set<AnyCancellable> = []
struct SavedFrame {
let window: NSRect
let screen: NSRect
@ -286,7 +290,26 @@ class BaseTerminalController: NSWindowController,
func surfaceTreeDidChange() {}
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {
let lastFocusedSurface = focusedSurface
focusedSurface = to
// Important to cancel any prior subscriptions
focusedSurfaceCancellables = []
// Setup our title listener. If we have a focused surface we always use that.
// Otherwise, we try to use our last focused surface. In either case, we only
// want to care if the surface is in the tree so we don't listen to titles of
// closed surfaces.
if let titleSurface = focusedSurface ?? lastFocusedSurface,
surfaceTree?.contains(view: titleSurface) ?? false {
// If we have a surface, we want to listen for title changes.
titleSurface.$title
.sink { [weak self] in self?.titleDidChange(to: $0) }
.store(in: &focusedSurfaceCancellables)
} else {
// There is no surface to listen to titles for.
titleDidChange(to: "👻")
}
}
func titleDidChange(to: String) {

View File

@ -8,9 +8,6 @@ protocol TerminalViewDelegate: AnyObject {
/// Called when the currently focused surface changed. This can be nil.
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?)
/// The title of the terminal should change.
func titleDidChange(to: String)
/// The URL of the pwd should change.
func pwdDidChange(to: URL?)
@ -59,19 +56,10 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
// Various state values sent back up from the currently focused terminals.
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle
@FocusedValue(\.ghosttySurfacePwd) private var surfacePwd
@FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit
@FocusedValue(\.ghosttySurfaceCellSize) private var cellSize
// The title for our window
private var title: String {
if let surfaceTitle, !surfaceTitle.isEmpty {
return surfaceTitle
}
return "👻"
}
// The pwd of the focused surface as a URL
private var pwdURL: URL? {
guard let surfacePwd, surfacePwd != "" else { return nil }
@ -105,9 +93,6 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
self.delegate?.focusedSurfaceDidChange(to: newValue)
}
}
.onChange(of: title) { newValue in
self.delegate?.titleDidChange(to: newValue)
}
.onChange(of: pwdURL) { newValue in
self.delegate?.pwdDidChange(to: newValue)
}

View File

@ -45,8 +45,6 @@ extension Ghostty {
/// this one.
@Binding var zoomedSurface: SurfaceView?
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String?
var body: some View {
let center = NotificationCenter.default
let pubZoom = center.publisher(for: Notification.didToggleSplitZoom)
@ -77,7 +75,6 @@ extension Ghostty {
.onReceive(pubZoom) { onZoom(notification: $0) }
}
}
.navigationTitle(surfaceTitle ?? "Ghostty")
.id(node) // Needed for change detection on node
} else {
// On these events we want to reset the split state and call it.

View File

@ -31,7 +31,6 @@ extension Ghostty {
}, right: {
InspectorViewRepresentable(surfaceView: surfaceView)
.focused($inspectorFocus)
.focusedValue(\.ghosttySurfaceTitle, surfaceView.title)
.focusedValue(\.ghosttySurfaceView, surfaceView)
})
}

View File

@ -6,14 +6,12 @@ extension Ghostty {
/// Render a terminal for the active app in the environment.
struct Terminal: View {
@EnvironmentObject private var ghostty: Ghostty.App
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String?
var body: some View {
if let app = self.ghostty.app {
SurfaceForApp(app) { surfaceView in
SurfaceWrapper(surfaceView: surfaceView)
}
.navigationTitle(surfaceTitle ?? "Ghostty")
}
}
}
@ -83,7 +81,6 @@ extension Ghostty {
Surface(view: surfaceView, size: geo.size)
.focused($surfaceFocus)
.focusedValue(\.ghosttySurfaceTitle, title)
.focusedValue(\.ghosttySurfacePwd, surfaceView.pwd)
.focusedValue(\.ghosttySurfaceView, surfaceView)
.focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize)
@ -496,15 +493,6 @@ extension FocusedValues {
typealias Value = Ghostty.SurfaceView
}
var ghosttySurfaceTitle: String? {
get { self[FocusedGhosttySurfaceTitle.self] }
set { self[FocusedGhosttySurfaceTitle.self] = newValue }
}
struct FocusedGhosttySurfaceTitle: FocusedValueKey {
typealias Value = String
}
var ghosttySurfacePwd: String? {
get { self[FocusedGhosttySurfacePwd.self] }
set { self[FocusedGhosttySurfacePwd.self] = newValue }