Adopt @Observable in SwiftUI code

This moves off of ObservableObject and @Published over to @Observable, which is preferred as it allows views
to observe changes on a per-property basis rather than an entire object at a time.

I went through the app and tried to compare behavior where I could, and I couldn't spot any visual differences,
but I am sure there's good chance I have missed something.

This also requires updating the minimum macOS deployment target to 14.0. If that is unacceptable, I am happy just
closing this PR.
This commit is contained in:
Harlan Haskins
2025-01-03 10:41:41 -05:00
parent e2f9eb6a6f
commit eda7f10529
17 changed files with 92 additions and 77 deletions

View File

@ -784,7 +784,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1;
"OTHER_LDFLAGS[arch=*]" = "-lstdc++";
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty;
@ -953,7 +953,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1;
"OTHER_LDFLAGS[arch=*]" = "-lstdc++";
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty.debug;
@ -1006,7 +1006,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 13.0;
MACOSX_DEPLOYMENT_TARGET = 14.0;
MARKETING_VERSION = 0.1;
"OTHER_LDFLAGS[arch=*]" = "-lstdc++";
PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty;

View File

@ -2,18 +2,18 @@ import SwiftUI
@main
struct Ghostty_iOSApp: App {
@StateObject private var ghostty_app = Ghostty.App()
@State private var ghostty_app = Ghostty.App()
var body: some Scene {
WindowGroup {
iOS_GhosttyTerminal()
.environmentObject(ghostty_app)
.environment(ghostty_app)
}
}
}
struct iOS_GhosttyTerminal: View {
@EnvironmentObject private var ghostty_app: Ghostty.App
@Environment(Ghostty.App.self) private var ghostty_app: Ghostty.App
var body: some View {
ZStack {
@ -26,7 +26,7 @@ struct iOS_GhosttyTerminal: View {
}
struct iOS_GhosttyInitView: View {
@EnvironmentObject private var ghostty_app: Ghostty.App
@Environment(Ghostty.App.self) private var ghostty_app: Ghostty.App
var body: some View {
VStack {

View File

@ -4,8 +4,8 @@ import OSLog
import Sparkle
import GhosttyKit
@Observable
class AppDelegate: NSObject,
ObservableObject,
NSApplicationDelegate,
UNUserNotificationCenterDelegate,
GhosttyAppDelegate
@ -99,7 +99,7 @@ class AppDelegate: NSObject,
private var appearanceObserver: NSKeyValueObservation? = nil
/// The custom app icon image that is currently in use.
@Published private(set) var appIcon: NSImage? = nil {
private(set) var appIcon: NSImage? = nil {
didSet {
NSApplication.shared.applicationIconImage = appIcon
}

View File

@ -12,7 +12,8 @@ import OSLog
// it. You have to yield secure input on application deactivation (because
// it'll affect other apps) and reacquire on reactivation, and every enable
// needs to be balanced with a disable.
class SecureInput : ObservableObject {
@Observable
class SecureInput {
static let shared = SecureInput()
private static let logger = Logger(
@ -28,10 +29,11 @@ class SecureInput : ObservableObject {
}
// The scoped objects and whether they're currently in focus.
@ObservationIgnored
private var scoped: [ObjectIdentifier: Bool] = [:]
// This is set to true when we've successfully called EnableSecureInput.
@Published private(set) var enabled: Bool = false
private(set) var enabled: Bool = false
// This is true if we want to enable secure input. We want to enable
// secure input if its enabled globally or any of the scoped objects are

View File

@ -1,11 +1,11 @@
import SwiftUI
protocol ConfigurationErrorsViewModel: ObservableObject {
protocol ConfigurationErrorsViewModel: AnyObject, Observable {
var errors: [String] { get set }
}
struct ConfigurationErrorsView<ViewModel: ConfigurationErrorsViewModel>: View {
@ObservedObject var model: ViewModel
var model: ViewModel
var body: some View {
VStack {

View File

@ -2,7 +2,7 @@ import SwiftUI
struct SettingsView: View {
// We need access to our app delegate to know if we're quitting or not.
@EnvironmentObject private var appDelegate: AppDelegate
@Environment(AppDelegate.self) private var appDelegate: AppDelegate
var body: some View {
HStack {

View File

@ -25,6 +25,7 @@ import GhosttyKit
///
/// The primary idea of all the behaviors we don't implement here are that subclasses may not
/// want these behaviors.
@Observable
class BaseTerminalController: NSWindowController,
NSWindowDelegate,
TerminalViewDelegate,
@ -36,12 +37,13 @@ class BaseTerminalController: NSWindowController,
let ghostty: Ghostty.App
/// The currently focused surface.
@ObservationIgnored
var focusedSurface: Ghostty.SurfaceView? = nil {
didSet { syncFocusToSurfaceTree() }
}
/// The surface tree for this window.
@Published var surfaceTree: Ghostty.SplitNode? = nil {
var surfaceTree: Ghostty.SplitNode? = nil {
didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) }
}

View File

@ -610,21 +610,22 @@ class TerminalController: BaseTerminalController {
syncAppearance(focusedSurface.derivedConfig)
// We also want to get notified of certain changes to update our appearance.
focusedSurface.$derivedConfig
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
.store(in: &surfaceAppearanceCancellables)
focusedSurface.$backgroundColor
.sink { [weak self, weak focusedSurface] _ in self?.syncAppearanceOnPropertyChange(focusedSurface) }
.store(in: &surfaceAppearanceCancellables)
observeFocusedSurface(focusedSurface)
}
private func syncAppearanceOnPropertyChange(_ surface: Ghostty.SurfaceView?) {
guard let surface else { return }
DispatchQueue.main.async { [weak self, weak surface] in
guard let surface else { return }
func observeFocusedSurface(_ focusedSurface: Ghostty.SurfaceView) {
/// Use Observable to observe properties that will invalidate the surface
/// appearance.
withObservationTracking {
_ = focusedSurface.derivedConfig
_ = focusedSurface.backgroundColor
} onChange: { [weak self] in
guard let self else { return }
guard self.focusedSurface == surface else { return }
self.syncAppearance(surface.derivedConfig)
DispatchQueue.main.async {
guard self.focusedSurface == focusedSurface else { return }
self.syncAppearance(focusedSurface.derivedConfig)
self.observeFocusedSurface(focusedSurface)
}
}
}

View File

@ -28,18 +28,18 @@ protocol TerminalViewDelegate: AnyObject {
/// 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 {
protocol TerminalViewModel: AnyObject, Observable {
/// The tree of terminal surfaces (splits) within the view. This is mutated by TerminalView
/// and children. This should be @Published.
/// and children.
var surfaceTree: Ghostty.SplitNode? { get set }
}
/// The main terminal view. This terminal view supports splits.
struct TerminalView<ViewModel: TerminalViewModel>: View {
@ObservedObject var ghostty: Ghostty.App
var ghostty: Ghostty.App
// The required view model
@ObservedObject var viewModel: ViewModel
@Bindable var viewModel: ViewModel
// An optional delegate to receive information about terminal changes.
weak var delegate: (any TerminalViewDelegate)? = nil
@ -86,9 +86,9 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) {
DebugBuildWarningView()
}
Ghostty.TerminalSplit(node: $viewModel.surfaceTree)
.environmentObject(ghostty)
.environment(ghostty)
.focused($focused)
.onAppear { self.focused = true }
.onChange(of: focusedSurface) { newValue in

View File

@ -13,7 +13,8 @@ protocol GhosttyAppDelegate: AnyObject {
extension Ghostty {
// IMPORTANT: THIS IS NOT DONE.
// This is a refactor/redo of Ghostty.AppState so that it supports both macOS and iOS
class App: ObservableObject {
@Observable
class App {
enum Readiness: String {
case loading, error, ready
}
@ -22,16 +23,16 @@ extension Ghostty {
weak var delegate: GhosttyAppDelegate?
/// The readiness value of the state.
@Published var readiness: Readiness = .loading
var readiness: Readiness = .loading
/// The global app configuration. This defines the app level configuration plus any behavior
/// for new windows, tabs, etc. Note that when creating a new window, it may inherit some
/// configuration (i.e. font size) from the previously focused window. This would override this.
@Published private(set) var config: Config
private(set) var config: Config
/// The ghostty app instance. We only have one of these for the entire app, although I guess
/// in theory you can have multiple... I don't know why you would...
@Published var app: ghostty_app_t? = nil {
var app: ghostty_app_t? = nil {
didSet {
guard let old = oldValue else { return }
ghostty_app_free(old)

View File

@ -3,7 +3,7 @@ import GhosttyKit
extension Ghostty {
/// Maps to a `ghostty_config_t` and the various operations on that.
class Config: ObservableObject {
class Config {
// The underlying C pointer to the Ghostty config structure. This
// should never be accessed directly. Any operations on this should
// be called from the functions on this or another class.

View File

@ -195,9 +195,10 @@ extension Ghostty {
}
}
class Leaf: ObservableObject, Equatable, Hashable, Codable {
@Observable
class Leaf: Equatable, Hashable, Codable {
let app: ghostty_app_t
@Published var surface: SurfaceView
var surface: SurfaceView
weak var parent: SplitNode.Container?
@ -250,13 +251,14 @@ extension Ghostty {
}
}
class Container: ObservableObject, Equatable, Hashable, Codable {
@Observable
class Container: Equatable, Hashable, Codable {
let app: ghostty_app_t
let direction: SplitViewDirection
@Published var topLeft: SplitNode
@Published var bottomRight: SplitNode
@Published var split: CGFloat = 0.5
var topLeft: SplitNode
var bottomRight: SplitNode
var split: CGFloat = 0.5
var resizeEvent: PassthroughSubject<Double, Never> = .init()

View File

@ -294,11 +294,11 @@ extension Ghostty {
/// This represents a split view that is in the horizontal or vertical split state.
private struct TerminalSplitContainer: View {
@EnvironmentObject var ghostty: Ghostty.App
@Environment(Ghostty.App.self) var ghostty: Ghostty.App
let neighbors: SplitNode.Neighbors
@Binding var node: SplitNode?
@StateObject var container: SplitNode.Container
@Bindable var container: SplitNode.Container
var body: some View {
SplitView(

View File

@ -6,10 +6,10 @@ import GhosttyKit
extension Ghostty {
/// InspectableSurface is a type of Surface view that allows an inspector to be attached.
struct InspectableSurface: View {
@EnvironmentObject var ghostty: Ghostty.App
@Environment(Ghostty.App.self) var ghostty: Ghostty.App
/// Same as SurfaceWrapper, see the doc comments there.
@ObservedObject var surfaceView: SurfaceView
var surfaceView: SurfaceView
var isSplit: Bool = false
// Maintain whether our view has focus or not

View File

@ -5,7 +5,7 @@ import GhosttyKit
extension Ghostty {
/// Render a terminal for the active app in the environment.
struct Terminal: View {
@EnvironmentObject private var ghostty: Ghostty.App
@Environment(Ghostty.App.self) private var ghostty: Ghostty.App
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String?
var body: some View {
@ -22,10 +22,10 @@ extension Ghostty {
struct SurfaceForApp<Content: View>: View {
let content: ((SurfaceView) -> Content)
@StateObject private var surfaceView: SurfaceView
@State private var surfaceView: SurfaceView
init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) {
_surfaceView = StateObject(wrappedValue: SurfaceView(app))
_surfaceView = State(wrappedValue: SurfaceView(app))
self.content = content
}
@ -37,7 +37,7 @@ extension Ghostty {
struct SurfaceWrapper: View {
// The surface to create a view for. This must be created upstream. As long as this
// remains the same, the surface that is being rendered remains the same.
@ObservedObject var surfaceView: SurfaceView
var surfaceView: SurfaceView
// True if this surface is part of a split view. This is important to know so
// we know whether to dim the surface out of focus.
@ -54,10 +54,15 @@ extension Ghostty {
#if canImport(AppKit)
// Observe SecureInput to detect when its enabled
@ObservedObject private var secureInput = SecureInput.shared
private var secureInput = SecureInput.shared
#endif
init(surfaceView: SurfaceView, isSplit: Bool = false) {
self.surfaceView = surfaceView
self.isSplit = isSplit
}
@EnvironmentObject private var ghostty: Ghostty.App
@Environment(Ghostty.App.self) private var ghostty: Ghostty.App
var body: some View {
let center = NotificationCenter.default

View File

@ -5,55 +5,56 @@ import GhosttyKit
extension Ghostty {
/// The NSView implementation for a terminal surface.
class SurfaceView: OSView, ObservableObject {
@Observable
class SurfaceView: OSView {
/// Unique ID per surface
let uuid: UUID
// The current title of the surface as defined by the pty. This can be
// changed with escape codes. This is public because the callbacks go
// to the app level and it is set from there.
@Published private(set) var title: String = "👻"
private(set) var title: String = "👻"
// The current pwd of the surface as defined by the pty. This can be
// changed with escape codes.
@Published var pwd: String? = nil
var pwd: String? = nil
// The cell size of this surface. This is set by the core when the
// surface is first created and any time the cell size changes (i.e.
// when the font size changes). This is used to allow windows to be
// resized in discrete steps of a single cell.
@Published var cellSize: NSSize = .zero
var cellSize: NSSize = .zero
// The health state of the surface. This currently only reflects the
// renderer health. In the future we may want to make this an enum.
@Published var healthy: Bool = true
var healthy: Bool = true
// Any error while initializing the surface.
@Published var error: Error? = nil
var error: Error? = nil
// The hovered URL string
@Published var hoverUrl: String? = nil
var hoverUrl: String? = nil
// The currently active key sequence. The sequence is not active if this is empty.
@Published var keySequence: [Ghostty.KeyEquivalent] = []
var keySequence: [Ghostty.KeyEquivalent] = []
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.
@Published var focusInstant: ContinuousClock.Instant? = nil
var focusInstant: ContinuousClock.Instant? = nil
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.
@Published var surfaceSize: ghostty_surface_size_s? = nil
var surfaceSize: ghostty_surface_size_s? = nil
// Whether the pointer should be visible or not
@Published private(set) var pointerStyle: BackportPointerStyle = .default
private(set) var pointerStyle: BackportPointerStyle = .default
/// The configuration derived from the Ghostty config so we don't need to rely on references.
@Published private(set) var derivedConfig: DerivedConfig
private(set) var derivedConfig: DerivedConfig
/// The background color within the color palette of the surface. This is only set if it is
/// dynamically updated. Otherwise, the background color is the default background color.
@Published private(set) var backgroundColor: Color? = nil
private(set) var backgroundColor: Color? = nil
// An initial size to request for a window. This will only affect
// then the view is moved to a new window.
@ -89,7 +90,7 @@ extension Ghostty {
}
// True if the inspector should be visible
@Published var inspectorVisible: Bool = false {
var inspectorVisible: Bool = false {
didSet {
if (oldValue && !inspectorVisible) {
guard let surface = self.surface else { return }

View File

@ -3,37 +3,38 @@ import GhosttyKit
extension Ghostty {
/// The UIView implementation for a terminal surface.
class SurfaceView: UIView, ObservableObject {
@Observable
class SurfaceView: UIView {
/// Unique ID per surface
let uuid: UUID
// The current title of the surface as defined by the pty. This can be
// changed with escape codes. This is public because the callbacks go
// to the app level and it is set from there.
@Published var title: String = "👻"
var title: String = "👻"
// The current pwd of the surface.
@Published var pwd: String? = nil
var pwd: String? = nil
// The cell size of this surface. This is set by the core when the
// surface is first created and any time the cell size changes (i.e.
// when the font size changes). This is used to allow windows to be
// resized in discrete steps of a single cell.
@Published var cellSize: OSSize = .zero
var cellSize: OSSize = .zero
// The health state of the surface. This currently only reflects the
// renderer health. In the future we may want to make this an enum.
@Published var healthy: Bool = true
var healthy: Bool = true
// Any error while initializing the surface.
@Published var error: Error? = nil
var error: Error? = nil
// The hovered URL
@Published var hoverUrl: String? = nil
var hoverUrl: String? = nil
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.
@Published var focusInstant: ContinuousClock.Instant? = nil
var focusInstant: ContinuousClock.Instant? = nil
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.