mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-15 16:26:08 +03:00

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.
159 lines
6.6 KiB
Swift
159 lines
6.6 KiB
Swift
import SwiftUI
|
|
import GhosttyKit
|
|
|
|
/// This delegate is notified of actions and property changes regarding the terminal view. This
|
|
/// delegate is optional and can be used by a TerminalView caller to react to changes such as
|
|
/// titles being set, cell sizes being changed, etc.
|
|
protocol TerminalViewDelegate: AnyObject {
|
|
/// Called when the currently focused surface changed. This can be nil.
|
|
func focusedSurfaceDidChange(to: Ghostty.SurfaceView?)
|
|
|
|
/// The URL of the pwd should change.
|
|
func pwdDidChange(to: URL?)
|
|
|
|
/// The cell size changed.
|
|
func cellSizeDidChange(to: NSSize)
|
|
|
|
/// The surface tree did change in some way, i.e. a split was added, removed, etc. This is
|
|
/// not called initially.
|
|
func surfaceTreeDidChange()
|
|
|
|
/// This is called when a split is zoomed.
|
|
func zoomStateDidChange(to: Bool)
|
|
|
|
/// Perform an action. At the time of writing this is only triggered by the command palette.
|
|
func performAction(_ action: String, on: Ghostty.SurfaceView)
|
|
}
|
|
|
|
/// The view model is a required implementation for TerminalView callers. This contains
|
|
/// the main state between the TerminalView caller and SwiftUI. This abstraction is what
|
|
/// allows AppKit to own most of the data in SwiftUI.
|
|
protocol TerminalViewModel: ObservableObject {
|
|
/// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView
|
|
/// and children. This should be @Published.
|
|
var surfaceTree: Ghostty.SplitNode? { get set }
|
|
|
|
/// The command palette state.
|
|
var commandPaletteIsShowing: Bool { get set }
|
|
}
|
|
|
|
/// The main terminal view. This terminal view supports splits.
|
|
struct TerminalView<ViewModel: TerminalViewModel>: View {
|
|
@ObservedObject var ghostty: Ghostty.App
|
|
|
|
// The required view model
|
|
@ObservedObject var viewModel: ViewModel
|
|
|
|
// An optional delegate to receive information about terminal changes.
|
|
weak var delegate: (any TerminalViewDelegate)? = nil
|
|
|
|
// The most recently focused surface, equal to focusedSurface when
|
|
// it is non-nil.
|
|
@State private var lastFocusedSurface: Weak<Ghostty.SurfaceView> = .init()
|
|
|
|
// This seems like a crutch after switching from SwiftUI to AppKit lifecycle.
|
|
@FocusState private var focused: Bool
|
|
|
|
// Various state values sent back up from the currently focused terminals.
|
|
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
|
|
@FocusedValue(\.ghosttySurfacePwd) private var surfacePwd
|
|
@FocusedValue(\.ghosttySurfaceZoomed) private var zoomedSplit
|
|
@FocusedValue(\.ghosttySurfaceCellSize) private var cellSize
|
|
|
|
// The pwd of the focused surface as a URL
|
|
private var pwdURL: URL? {
|
|
guard let surfacePwd, surfacePwd != "" else { return nil }
|
|
return URL(fileURLWithPath: surfacePwd)
|
|
}
|
|
|
|
var body: some View {
|
|
switch ghostty.readiness {
|
|
case .loading:
|
|
Text("Loading")
|
|
case .error:
|
|
ErrorView()
|
|
case .ready:
|
|
ZStack {
|
|
VStack(spacing: 0) {
|
|
// If we're running in debug mode we show a warning so that users
|
|
// know that performance will be degraded.
|
|
if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) {
|
|
DebugBuildWarningView()
|
|
}
|
|
|
|
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
|
|
.environmentObject(ghostty)
|
|
.focused($focused)
|
|
.onAppear { self.focused = true }
|
|
.onChange(of: focusedSurface) { newValue in
|
|
// We want to keep track of our last focused surface so even if
|
|
// we lose focus we keep this set to the last non-nil value.
|
|
if newValue != nil {
|
|
lastFocusedSurface = .init(newValue)
|
|
self.delegate?.focusedSurfaceDidChange(to: newValue)
|
|
}
|
|
}
|
|
.onChange(of: pwdURL) { newValue in
|
|
self.delegate?.pwdDidChange(to: newValue)
|
|
}
|
|
.onChange(of: cellSize) { newValue in
|
|
guard let size = newValue else { return }
|
|
self.delegate?.cellSizeDidChange(to: size)
|
|
}
|
|
.onChange(of: viewModel.surfaceTree?.hashValue) { _ in
|
|
// This is funky, but its the best way I could think of to detect
|
|
// ANY CHANGE within the deeply nested surface tree -- detecting a change
|
|
// in the hash value.
|
|
self.delegate?.surfaceTreeDidChange()
|
|
}
|
|
.onChange(of: zoomedSplit) { newValue in
|
|
self.delegate?.zoomStateDidChange(to: newValue ?? false)
|
|
}
|
|
}
|
|
// Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style
|
|
.ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : [])
|
|
|
|
if let surfaceView = lastFocusedSurface.value {
|
|
TerminalCommandPaletteView(
|
|
surfaceView: surfaceView,
|
|
isPresented: $viewModel.commandPaletteIsShowing,
|
|
ghosttyConfig: ghostty.config) { action in
|
|
self.delegate?.performAction(action, on: surfaceView)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct DebugBuildWarningView: View {
|
|
@State private var isPopover = false
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Spacer()
|
|
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundColor(.yellow)
|
|
|
|
Text("You're running a debug build of Ghostty! Performance will be degraded.")
|
|
.padding(.all, 8)
|
|
.popover(isPresented: $isPopover, arrowEdge: .bottom) {
|
|
Text("""
|
|
Debug builds of Ghostty are very slow and you may experience
|
|
performance problems. Debug builds are only recommended during
|
|
development.
|
|
""")
|
|
.padding(.all)
|
|
}
|
|
|
|
Spacer()
|
|
}
|
|
.background(Color(.windowBackgroundColor))
|
|
.frame(maxWidth: .infinity)
|
|
.onTapGesture {
|
|
isPopover = true
|
|
}
|
|
}
|
|
}
|