mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
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:
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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) }
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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.
|
||||
|
@ -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()
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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 }
|
||||
|
@ -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.
|
||||
|
Reference in New Issue
Block a user