macOS: clean up duplicated declarations in SurfaceView_AppKit/UIKit (#12250)

Added a shared `OSSurfaceView` as the base class to share common
variables and functions across platforms.

Each commit contains a small change to move one or two variables or
functions to `OSSurfaceView`.
This commit is contained in:
Mitchell Hashimoto
2026-04-12 13:15:52 -07:00
committed by GitHub
4 changed files with 157 additions and 166 deletions

View File

@@ -0,0 +1,139 @@
import Foundation
import GhosttyKit
extension Ghostty {
class OSSurfaceView: OSView, ObservableObject {
typealias ID = UUID
/// Unique ID per surface
let id: UUID
// The current pwd of the surface as defined by the pty. This can be
// changed with escape codes.
@Published var pwd: String?
// 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: CGSize = .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
// Any error while initializing the surface.
@Published var error: Error?
// The hovered URL string
@Published var hoverUrl: String?
// The progress report (if any)
@Published var progressReport: Action.ProgressReport?
// The currently active key tables. Empty if no tables are active.
@Published var keyTables: [String] = []
// The current search state. When non-nil, the search overlay should be shown.
@Published var searchState: SearchState?
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.
@Published var focusInstant: ContinuousClock.Instant?
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.
@Published var surfaceSize: ghostty_surface_size_s?
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
@Published private(set) var highlighted: Bool = false
var surface: ghostty_surface_t? {
nil
}
init(id: UUID?, frame: CGRect) {
self.id = id ?? UUID()
super.init(frame: frame)
// Before we initialize the surface we want to register our notifications
// so there is no window where we can't receive them.
let center = NotificationCenter.default
center.addObserver(
self,
selector: #selector(ghosttyDidChangeReadonly(_:)),
name: .ghosttyDidChangeReadonly,
object: self,
)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) is not supported for this view")
}
deinit {
NotificationCenter.default
.removeObserver(self)
}
@objc private func ghosttyDidChangeReadonly(_ notification: Foundation.Notification) {
guard let value = notification.userInfo?[Foundation.Notification.Name.ReadonlyKey] as? Bool else { return }
readonly = value
}
/// Triggers a brief highlight animation on this surface.
func highlight() {
highlighted = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.highlighted = false
}
}
// MARK: - Placeholders
func focusDidChange(_ focused: Bool) {}
func sizeDidChange(_ size: CGSize) {}
}
}
// MARK: Search State
extension Ghostty.OSSurfaceView {
class SearchState: ObservableObject {
@Published var needle: String = ""
@Published var selected: UInt?
@Published var total: UInt?
init(from startSearch: Ghostty.Action.StartSearch) {
self.needle = startSearch.needle ?? ""
}
}
func navigateSearchToNext() -> Bool {
guard let surface = self.surface else { return false }
let action = "navigate_search:next"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
#if canImport(AppKit)
AppDelegate.logger.warning("action failed action=\(action)")
#endif
return false
}
return true
}
func navigateSearchToPrevious() -> Bool {
guard let surface = self.surface else { return false }
let action = "navigate_search:previous"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
#if canImport(AppKit)
AppDelegate.logger.warning("action failed action=\(action)")
#endif
return false
}
return true
}
}

View File

@@ -1267,41 +1267,3 @@ extension FocusedValues {
typealias Value = OSSize
}
}
// MARK: Search State
extension Ghostty.SurfaceView {
class SearchState: ObservableObject {
@Published var needle: String = ""
@Published var selected: UInt?
@Published var total: UInt?
init(from startSearch: Ghostty.Action.StartSearch) {
self.needle = startSearch.needle ?? ""
}
}
func navigateSearchToNext() -> Bool {
guard let surface = self.surface else { return false }
let action = "navigate_search:next"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
#if canImport(AppKit)
AppDelegate.logger.warning("action failed action=\(action)")
#endif
return false
}
return true
}
func navigateSearchToPrevious() -> Bool {
guard let surface = self.surface else { return false }
let action = "navigate_search:previous"
if !ghostty_surface_binding_action(surface, action, UInt(action.lengthOfBytes(using: .utf8))) {
#if canImport(AppKit)
AppDelegate.logger.warning("action failed action=\(action)")
#endif
return false
}
return true
}
}

View File

@@ -7,15 +7,9 @@ import GhosttyKit
extension Ghostty {
/// The NSView implementation for a terminal surface.
class SurfaceView: OSView, ObservableObject, Codable, Identifiable {
typealias ID = UUID
/// Unique ID per surface
let id: UUID
class SurfaceView: OSSurfaceView, Codable, Identifiable {
// 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.
// changed with escape codes.
@Published private(set) var title: String = "" {
didSet {
if !title.isEmpty {
@@ -25,28 +19,8 @@ extension Ghostty {
}
}
// The current pwd of the surface as defined by the pty. This can be
// changed with escape codes.
@Published var pwd: String?
// 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
// 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
// Any error while initializing the surface.
@Published var error: Error?
// The hovered URL string
@Published var hoverUrl: String?
// The progress report (if any)
@Published var progressReport: Action.ProgressReport? {
override var progressReport: Action.ProgressReport? {
didSet {
// Cancel any existing timer
progressReportTimer?.invalidate()
@@ -65,11 +39,8 @@ extension Ghostty {
// The currently active key sequence. The sequence is not active if this is empty.
@Published var keySequence: [KeyboardShortcut] = []
// The currently active key tables. Empty if no tables are active.
@Published var keyTables: [String] = []
// The current search state. When non-nil, the search overlay should be shown.
@Published var searchState: SearchState? {
override var searchState: SearchState? {
didSet {
if let searchState {
// I'm not a Combine expert so if there is a better way to do this I'm
@@ -105,14 +76,6 @@ extension Ghostty {
// Cancellable for search state needle changes
private var searchNeedleCancellable: AnyCancellable?
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.
@Published var focusInstant: ContinuousClock.Instant?
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.
@Published var surfaceSize: ghostty_surface_size_s?
// Whether the pointer should be visible or not
@Published private(set) var pointerStyle: CursorStyle = .horizontalText
@@ -136,12 +99,6 @@ extension Ghostty {
/// True when the bell is active. This is set inactive on focus or event.
@Published private(set) var bell: Bool = false
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
@Published private(set) var highlighted: Bool = false
// An initial size to request for a window. This will only affect
// then the view is moved to a new window.
var initialSize: NSSize?
@@ -207,7 +164,7 @@ extension Ghostty {
private(set) var surfaceModel: Ghostty.Surface?
/// Returns the underlying C value for the surface. See "note" on surfaceModel.
var surface: ghostty_surface_t? {
override var surface: ghostty_surface_t? {
surfaceModel?.unsafeCValue
}
/// Current scrollbar state, cached here for persistence across rebuilds
@@ -255,7 +212,6 @@ extension Ghostty {
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
self.markedText = NSMutableAttributedString()
self.id = uuid ?? .init()
// Our initial config always is our application wide config.
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
@@ -273,7 +229,7 @@ extension Ghostty {
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING.
super.init(frame: NSRect(x: 0, y: 0, width: 800, height: 600))
super.init(id: uuid, frame: NSRect(x: 0, y: 0, width: 800, height: 600))
// Our cache of screen data
cachedScreenContents = .init(duration: .milliseconds(500)) { [weak self] in
@@ -362,11 +318,6 @@ extension Ghostty {
selector: #selector(ghosttyBellDidRing(_:)),
name: .ghosttyBellDidRing,
object: self)
center.addObserver(
self,
selector: #selector(ghosttyDidChangeReadonly(_:)),
name: .ghosttyDidChangeReadonly,
object: self)
center.addObserver(
self,
selector: #selector(windowDidChangeScreen),
@@ -438,7 +389,7 @@ extension Ghostty {
progressReportTimer?.invalidate()
}
func focusDidChange(_ focused: Bool) {
override func focusDidChange(_ focused: Bool) {
guard let surface = self.surface else { return }
guard self.focused != focused else { return }
self.focused = focused
@@ -475,7 +426,7 @@ extension Ghostty {
}
}
func sizeDidChange(_ size: CGSize) {
override func sizeDidChange(_ size: CGSize) {
// Ghostty wants to know the actual framebuffer size... It is very important
// here that we use "size" and NOT the view frame. If we're in the middle of
// an animation (i.e. a fullscreen animation), the frame will not yet be updated.
@@ -785,11 +736,6 @@ extension Ghostty {
bell = true
}
@objc private func ghosttyDidChangeReadonly(_ notification: SwiftUI.Notification) {
guard let value = notification.userInfo?[SwiftUI.Notification.Name.ReadonlyKey] as? Bool else { return }
readonly = value
}
@objc private func windowDidChangeScreen(notification: SwiftUI.Notification) {
guard let window = self.window else { return }
guard let object = notification.object as? NSWindow, window == object else { return }
@@ -1627,14 +1573,6 @@ extension Ghostty {
}
}
/// Triggers a brief highlight animation on this surface.
func highlight() {
highlighted = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { [weak self] in
self?.highlighted = false
}
}
@IBAction func splitRight(_ sender: Any) {
guard let surface = self.surface else { return }
ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT)

View File

@@ -3,74 +3,26 @@ import GhosttyKit
extension Ghostty {
/// The UIView implementation for a terminal surface.
class SurfaceView: UIView, ObservableObject {
typealias ID = UUID
/// Unique ID per surface
let id: UUID
class SurfaceView: OSSurfaceView {
// 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 = "👻"
// The current pwd of the surface.
@Published var pwd: String?
// 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
// 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
// Any error while initializing the surface.
@Published var error: Error?
// The hovered URL
@Published var hoverUrl: String?
// The progress report (if any)
@Published var progressReport: Action.ProgressReport?
// The time this surface last became focused. This is a ContinuousClock.Instant
// on supported platforms.
@Published var focusInstant: ContinuousClock.Instant?
// changed with escape codes.
@Published private(set) var title: String = "👻"
/// True when the bell is active. This is set inactive on focus or event.
@Published var bell: Bool = false
// The current search state. When non-nil, the search overlay should be shown.
@Published var searchState: SearchState?
private(set) var _surface: ghostty_surface_t?
// The currently active key tables. Empty if no tables are active.
@Published var keyTables: [String] = []
/// True when the surface is in readonly mode.
@Published private(set) var readonly: Bool = false
/// True when the surface should show a highlight effect (e.g., when presented via goto_split).
@Published private(set) var highlighted: Bool = false
// Returns sizing information for the surface. This is the raw C
// structure because I'm lazy.
var surfaceSize: ghostty_surface_size_s? {
guard let surface = self.surface else { return nil }
return ghostty_surface_size(surface)
override var surface: ghostty_surface_t? {
_surface
}
private(set) var surface: ghostty_surface_t?
init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) {
self.id = uuid ?? .init()
// Initialize with some default frame size. The important thing is that this
// is non-zero so that our layer bounds are non-zero so that our renderer
// can do SOMETHING.
super.init(frame: CGRect(x: 0, y: 0, width: 800, height: 600))
super.init(id: uuid, frame: CGRect(x: 0, y: 0, width: 800, height: 600))
// Setup our surface. This will also initialize all the terminal IO.
let surface_cfg = baseConfig ?? SurfaceConfiguration()
@@ -81,7 +33,7 @@ extension Ghostty {
// TODO
return
}
self.surface = surface
self._surface = surface
}
required init?(coder: NSCoder) {
@@ -93,7 +45,7 @@ extension Ghostty {
ghostty_surface_free(surface)
}
func focusDidChange(_ focused: Bool) {
override func focusDidChange(_ focused: Bool) {
guard let surface = self.surface else { return }
ghostty_surface_set_focus(surface, focused)
@@ -103,7 +55,7 @@ extension Ghostty {
}
}
func sizeDidChange(_ size: CGSize) {
override func sizeDidChange(_ size: CGSize) {
guard let surface = self.surface else { return }
// Ghostty wants to know the actual framebuffer size... It is very important