mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-08-02 14:57:31 +03:00
macos: remove AppState and unify onto Ghostty.App cross-platform
This commit is contained in:
@ -13,6 +13,7 @@
|
||||
857F63812A5E64F200CA4815 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 857F63802A5E64F200CA4815 /* MainMenu.xib */; };
|
||||
A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
|
||||
A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = A514C8D52B54A16400493A16 /* Ghostty.Config.swift */; };
|
||||
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
|
||||
A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */; };
|
||||
A51BFC1E2B2FB5CE00E92F16 /* About.xib in Resources */ = {isa = PBXBuildFile; fileRef = A51BFC1D2B2FB5CE00E92F16 /* About.xib */; };
|
||||
A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC1F2B2FB64F00E92F16 /* AboutController.swift */; };
|
||||
@ -28,7 +29,6 @@
|
||||
A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; };
|
||||
A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
|
||||
A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; };
|
||||
A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB529B6F47F0055DE60 /* AppState.swift */; };
|
||||
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; };
|
||||
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; };
|
||||
A56B880B2A840447007A0E29 /* Carbon.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A56B880A2A840447007A0E29 /* Carbon.framework */; };
|
||||
@ -80,7 +80,6 @@
|
||||
A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = "<group>"; };
|
||||
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = "<group>"; };
|
||||
A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = "<group>"; };
|
||||
A55B7BB529B6F47F0055DE60 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
|
||||
A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = "<group>"; };
|
||||
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = "<group>"; };
|
||||
A56B880A2A840447007A0E29 /* Carbon.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Carbon.framework; path = System/Library/Frameworks/Carbon.framework; sourceTree = SDKROOT; };
|
||||
@ -236,7 +235,6 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
A55B7BB729B6F53A0055DE60 /* Package.swift */,
|
||||
A55B7BB529B6F47F0055DE60 /* AppState.swift */,
|
||||
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */,
|
||||
A59FB5CE2AE0DB50009128F3 /* InspectorView.swift */,
|
||||
A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */,
|
||||
@ -467,7 +465,6 @@
|
||||
A5FEB3002ABB69450068369E /* main.swift in Sources */,
|
||||
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
|
||||
A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */,
|
||||
A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */,
|
||||
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
|
||||
A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */,
|
||||
A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */,
|
||||
@ -480,6 +477,7 @@
|
||||
A596309E2AEE1D6C00D64628 /* TerminalView.swift in Sources */,
|
||||
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */,
|
||||
A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */,
|
||||
A514C8D82B54DC6800493A16 /* Ghostty.App.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
@ -8,7 +8,7 @@ class AppDelegate: NSObject,
|
||||
ObservableObject,
|
||||
NSApplicationDelegate,
|
||||
UNUserNotificationCenterDelegate,
|
||||
GhosttyAppStateDelegate
|
||||
GhosttyAppDelegate
|
||||
{
|
||||
// The application logger. We should probably move this at some point to a dedicated
|
||||
// class/struct but for now it lives here! 🤷♂️
|
||||
@ -62,7 +62,7 @@ class AppDelegate: NSObject,
|
||||
private var applicationHasBecomeActive: Bool = false
|
||||
|
||||
/// The ghostty global state. Only one per process.
|
||||
let ghostty: Ghostty.AppState = Ghostty.AppState()
|
||||
let ghostty: Ghostty.App = Ghostty.App()
|
||||
|
||||
/// Manages our terminal windows.
|
||||
let terminalManager: TerminalManager
|
||||
@ -338,7 +338,7 @@ class AppDelegate: NSObject,
|
||||
withCompletionHandler(options)
|
||||
}
|
||||
|
||||
//MARK: - GhosttyAppStateDelegate
|
||||
//MARK: - GhosttyAppDelegate
|
||||
|
||||
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView? {
|
||||
for c in terminalManager.windows {
|
||||
@ -350,7 +350,7 @@ class AppDelegate: NSObject,
|
||||
return nil
|
||||
}
|
||||
|
||||
func configDidReload(_ state: Ghostty.AppState) {
|
||||
func configDidReload(_ state: Ghostty.App) {
|
||||
// Depending on the "window-save-state" setting we have to set the NSQuitAlwaysKeepsWindows
|
||||
// configuration. This is the only way to carefully control whether macOS invokes the
|
||||
// state restoration system.
|
||||
|
@ -11,7 +11,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
override var windowNibName: NSNib.Name? { "Terminal" }
|
||||
|
||||
/// The app instance that this terminal view will represent.
|
||||
let ghostty: Ghostty.AppState
|
||||
let ghostty: Ghostty.App
|
||||
|
||||
/// The currently focused surface.
|
||||
var focusedSurface: Ghostty.SurfaceView? = nil
|
||||
@ -46,7 +46,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
/// changes in the list.
|
||||
private var tabWindowsHash: Int = 0
|
||||
|
||||
init(_ ghostty: Ghostty.AppState,
|
||||
init(_ ghostty: Ghostty.App,
|
||||
withBaseConfig base: Ghostty.SurfaceConfiguration? = nil,
|
||||
withSurfaceTree tree: Ghostty.SplitNode? = nil
|
||||
) {
|
||||
@ -502,7 +502,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
str = cc.contents
|
||||
}
|
||||
|
||||
Ghostty.AppState.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true)
|
||||
Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -589,7 +589,7 @@ class TerminalController: NSWindowController, NSWindowDelegate,
|
||||
// If we already have a clipboard confirmation view up, we ignore this request.
|
||||
// This shouldn't be possible...
|
||||
guard self.clipboardConfirmation == nil else {
|
||||
Ghostty.AppState.completeClipboardRequest(surface, data: "", state: state, confirmed: true)
|
||||
Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,7 @@ class TerminalManager {
|
||||
let closePublisher: AnyCancellable
|
||||
}
|
||||
|
||||
let ghostty: Ghostty.AppState
|
||||
let ghostty: Ghostty.App
|
||||
|
||||
/// The currently focused surface of the main window.
|
||||
var focusedSurface: Ghostty.SurfaceView? { mainWindow?.controller.focusedSurface }
|
||||
@ -37,7 +37,7 @@ class TerminalManager {
|
||||
return windows.last
|
||||
}
|
||||
|
||||
init(_ ghostty: Ghostty.AppState) {
|
||||
init(_ ghostty: Ghostty.App) {
|
||||
self.ghostty = ghostty
|
||||
|
||||
let center = NotificationCenter.default
|
||||
|
@ -37,7 +37,7 @@ protocol TerminalViewModel: ObservableObject {
|
||||
|
||||
/// The main terminal view. This terminal view supports splits.
|
||||
struct TerminalView<ViewModel: TerminalViewModel>: View {
|
||||
@ObservedObject var ghostty: Ghostty.AppState
|
||||
@ObservedObject var ghostty: Ghostty.App
|
||||
|
||||
// The required view model
|
||||
@ObservedObject var viewModel: ViewModel
|
||||
@ -83,7 +83,7 @@ struct TerminalView<ViewModel: TerminalViewModel>: View {
|
||||
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) {
|
||||
if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG) {
|
||||
DebugBuildWarningView()
|
||||
}
|
||||
|
||||
|
@ -1,572 +0,0 @@
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import GhosttyKit
|
||||
|
||||
protocol GhosttyAppStateDelegate: AnyObject {
|
||||
/// Called when the configuration did finish reloading.
|
||||
func configDidReload(_ state: Ghostty.AppState)
|
||||
|
||||
/// Called when a callback needs access to a specific surface. This should return nil
|
||||
/// when the surface is no longer valid.
|
||||
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView?
|
||||
}
|
||||
|
||||
extension Ghostty {
|
||||
enum AppReadiness {
|
||||
case loading, error, ready
|
||||
}
|
||||
|
||||
enum FontSizeModification {
|
||||
case increase(Int)
|
||||
case decrease(Int)
|
||||
case reset
|
||||
}
|
||||
|
||||
struct Info {
|
||||
var mode: ghostty_build_mode_e
|
||||
var version: String
|
||||
}
|
||||
|
||||
/// The AppState is the global state that is associated with the Swift app. This handles initially
|
||||
/// initializing Ghostty, loading the configuration, etc.
|
||||
class AppState: ObservableObject {
|
||||
/// The readiness value of the state.
|
||||
@Published var readiness: AppReadiness = .loading
|
||||
|
||||
/// Optional delegate
|
||||
weak var delegate: GhosttyAppStateDelegate?
|
||||
|
||||
/// 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.
|
||||
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 {
|
||||
didSet {
|
||||
guard let old = oldValue else { return }
|
||||
ghostty_app_free(old)
|
||||
}
|
||||
}
|
||||
|
||||
/// True if we need to confirm before quitting.
|
||||
var needsConfirmQuit: Bool {
|
||||
guard let app = app else { return false }
|
||||
return ghostty_app_needs_confirm_quit(app)
|
||||
}
|
||||
|
||||
/// Build information
|
||||
var info: Info {
|
||||
let raw = ghostty_info()
|
||||
let version = NSString(
|
||||
bytes: raw.version,
|
||||
length: Int(raw.version_len),
|
||||
encoding: NSUTF8StringEncoding
|
||||
) ?? "unknown"
|
||||
|
||||
return Info(mode: raw.build_mode, version: String(version))
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize ghostty global state. This happens once per process.
|
||||
if ghostty_init() != GHOSTTY_SUCCESS {
|
||||
AppDelegate.logger.critical("ghostty_init failed, weird things may happen")
|
||||
readiness = .error
|
||||
}
|
||||
|
||||
// Initialize the global configuration.
|
||||
self.config = Config()
|
||||
if self.config.config == nil {
|
||||
readiness = .error
|
||||
return
|
||||
}
|
||||
|
||||
// Create our "runtime" config. The "runtime" is the configuration that ghostty
|
||||
// uses to interface with the application runtime environment.
|
||||
var runtime_cfg = ghostty_runtime_config_s(
|
||||
userdata: Unmanaged.passUnretained(self).toOpaque(),
|
||||
supports_selection_clipboard: false,
|
||||
wakeup_cb: { userdata in AppState.wakeup(userdata) },
|
||||
reload_config_cb: { userdata in AppState.reloadConfig(userdata) },
|
||||
open_config_cb: { userdata in AppState.openConfig(userdata) },
|
||||
set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) },
|
||||
set_mouse_shape_cb: { userdata, shape in AppState.setMouseShape(userdata, shape: shape) },
|
||||
set_mouse_visibility_cb: { userdata, visible in AppState.setMouseVisibility(userdata, visible: visible) },
|
||||
read_clipboard_cb: { userdata, loc, state in AppState.readClipboard(userdata, location: loc, state: state) },
|
||||
confirm_read_clipboard_cb: { userdata, str, state, request in AppState.confirmReadClipboard(userdata, string: str, state: state, request: request ) },
|
||||
write_clipboard_cb: { userdata, str, loc, confirm in AppState.writeClipboard(userdata, string: str, location: loc, confirm: confirm) },
|
||||
new_split_cb: { userdata, direction, surfaceConfig in AppState.newSplit(userdata, direction: direction, config: surfaceConfig) },
|
||||
new_tab_cb: { userdata, surfaceConfig in AppState.newTab(userdata, config: surfaceConfig) },
|
||||
new_window_cb: { userdata, surfaceConfig in AppState.newWindow(userdata, config: surfaceConfig) },
|
||||
control_inspector_cb: { userdata, mode in AppState.controlInspector(userdata, mode: mode) },
|
||||
close_surface_cb: { userdata, processAlive in AppState.closeSurface(userdata, processAlive: processAlive) },
|
||||
focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) },
|
||||
resize_split_cb: { userdata, direction, amount in
|
||||
AppState.resizeSplit(userdata, direction: direction, amount: amount) },
|
||||
equalize_splits_cb: { userdata in
|
||||
AppState.equalizeSplits(userdata) },
|
||||
toggle_split_zoom_cb: { userdata in AppState.toggleSplitZoom(userdata) },
|
||||
goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) },
|
||||
toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) },
|
||||
set_initial_window_size_cb: { userdata, width, height in AppState.setInitialWindowSize(userdata, width: width, height: height) },
|
||||
render_inspector_cb: { userdata in AppState.renderInspector(userdata) },
|
||||
set_cell_size_cb: { userdata, width, height in AppState.setCellSize(userdata, width: width, height: height) },
|
||||
show_desktop_notification_cb: { userdata, title, body in
|
||||
AppState.showUserNotification(userdata, title: title, body: body)
|
||||
}
|
||||
)
|
||||
|
||||
// Create the ghostty app.
|
||||
guard let app = ghostty_app_new(&runtime_cfg, config.config) else {
|
||||
AppDelegate.logger.critical("ghostty_app_new failed")
|
||||
readiness = .error
|
||||
return
|
||||
}
|
||||
self.app = app
|
||||
|
||||
// Subscribe to notifications for keyboard layout change so that we can update Ghostty.
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(self.keyboardSelectionDidChange(notification:)),
|
||||
name: NSTextInputContext.keyboardSelectionDidChangeNotification,
|
||||
object: nil)
|
||||
|
||||
self.readiness = .ready
|
||||
}
|
||||
|
||||
deinit {
|
||||
// This will force the didSet callbacks to run which free.
|
||||
self.app = nil
|
||||
|
||||
// Remove our observer
|
||||
NotificationCenter.default.removeObserver(
|
||||
self,
|
||||
name: NSTextInputContext.keyboardSelectionDidChangeNotification,
|
||||
object: nil)
|
||||
}
|
||||
|
||||
func appTick() {
|
||||
guard let app = self.app else { return }
|
||||
|
||||
// Tick our app, which lets us know if we want to quit
|
||||
let exit = ghostty_app_tick(app)
|
||||
if (!exit) { return }
|
||||
|
||||
// We want to quit, start that process
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
|
||||
func openConfig() {
|
||||
guard let app = self.app else { return }
|
||||
ghostty_app_open_config(app)
|
||||
}
|
||||
|
||||
func reloadConfig() {
|
||||
guard let app = self.app else { return }
|
||||
ghostty_app_reload_config(app)
|
||||
}
|
||||
|
||||
/// Request that the given surface is closed. This will trigger the full normal surface close event
|
||||
/// cycle which will call our close surface callback.
|
||||
func requestClose(surface: ghostty_surface_t) {
|
||||
ghostty_surface_request_close(surface)
|
||||
}
|
||||
|
||||
func newTab(surface: ghostty_surface_t) {
|
||||
let action = "new_tab"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
func newWindow(surface: ghostty_surface_t) {
|
||||
let action = "new_window"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
func split(surface: ghostty_surface_t, direction: ghostty_split_direction_e) {
|
||||
ghostty_surface_split(surface, direction)
|
||||
}
|
||||
|
||||
func splitMoveFocus(surface: ghostty_surface_t, direction: SplitFocusDirection) {
|
||||
ghostty_surface_split_focus(surface, direction.toNative())
|
||||
}
|
||||
|
||||
func splitResize(surface: ghostty_surface_t, direction: SplitResizeDirection, amount: UInt16) {
|
||||
ghostty_surface_split_resize(surface, direction.toNative(), amount)
|
||||
}
|
||||
|
||||
func splitEqualize(surface: ghostty_surface_t) {
|
||||
ghostty_surface_split_equalize(surface)
|
||||
}
|
||||
|
||||
func splitToggleZoom(surface: ghostty_surface_t) {
|
||||
let action = "toggle_split_zoom"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
func toggleFullscreen(surface: ghostty_surface_t) {
|
||||
let action = "toggle_fullscreen"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
func changeFontSize(surface: ghostty_surface_t, _ change: FontSizeModification) {
|
||||
let action: String
|
||||
switch change {
|
||||
case .increase(let amount):
|
||||
action = "increase_font_size:\(amount)"
|
||||
case .decrease(let amount):
|
||||
action = "decrease_font_size:\(amount)"
|
||||
case .reset:
|
||||
action = "reset_font_size"
|
||||
}
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
func toggleTerminalInspector(surface: ghostty_surface_t) {
|
||||
let action = "inspector:toggle"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
AppDelegate.logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
// Called when the selected keyboard changes. We have to notify Ghostty so that
|
||||
// it can reload the keyboard mapping for input.
|
||||
@objc private func keyboardSelectionDidChange(notification: NSNotification) {
|
||||
guard let app = self.app else { return }
|
||||
ghostty_app_keyboard_changed(app)
|
||||
}
|
||||
|
||||
// MARK: Ghostty Callbacks
|
||||
|
||||
static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e, config: ghostty_surface_config_s) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(name: Notification.ghosttyNewSplit, object: surface, userInfo: [
|
||||
"direction": direction,
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config),
|
||||
])
|
||||
}
|
||||
|
||||
static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface, userInfo: [
|
||||
"process_alive": processAlive,
|
||||
])
|
||||
}
|
||||
|
||||
static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyFocusSplit,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.SplitDirectionKey: splitDirection,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
guard let resizeDirection = SplitResizeDirection.from(direction: direction) else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didResizeSplit,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.ResizeSplitDirectionKey: resizeDirection,
|
||||
Notification.ResizeSplitAmountKey: amount,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func equalizeSplits(_ userdata: UnsafeMutableRawPointer?) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(name: Notification.didEqualizeSplits, object: surface)
|
||||
}
|
||||
|
||||
static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didToggleSplitZoom,
|
||||
object: surface
|
||||
)
|
||||
}
|
||||
|
||||
static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyGotoTab,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.GotoTabKey: n,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) {
|
||||
// If we don't even have a surface, something went terrible wrong so we have
|
||||
// to leak "state".
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
guard let surface = surfaceView.surface else { return }
|
||||
|
||||
// We only support the standard clipboard
|
||||
if (location != GHOSTTY_CLIPBOARD_STANDARD) {
|
||||
return completeClipboardRequest(surface, data: "", state: state)
|
||||
}
|
||||
|
||||
// Get our string
|
||||
let str = NSPasteboard.general.string(forType: .string) ?? ""
|
||||
completeClipboardRequest(surface, data: str, state: state)
|
||||
}
|
||||
|
||||
static func confirmReadClipboard(
|
||||
_ userdata: UnsafeMutableRawPointer?,
|
||||
string: UnsafePointer<CChar>?,
|
||||
state: UnsafeMutableRawPointer?,
|
||||
request: ghostty_clipboard_request_e
|
||||
) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
|
||||
guard let request = Ghostty.ClipboardRequest.from(request: request) else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.confirmClipboard,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.ConfirmClipboardStrKey: valueStr,
|
||||
Notification.ConfirmClipboardStateKey: state as Any,
|
||||
Notification.ConfirmClipboardRequestKey: request,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func completeClipboardRequest(
|
||||
_ surface: ghostty_surface_t,
|
||||
data: String,
|
||||
state: UnsafeMutableRawPointer?,
|
||||
confirmed: Bool = false
|
||||
) {
|
||||
data.withCString { ptr in
|
||||
ghostty_surface_complete_clipboard_request(surface, ptr, state, confirmed)
|
||||
}
|
||||
}
|
||||
|
||||
static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?, location: ghostty_clipboard_e, confirm: Bool) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
|
||||
// We only support the standard clipboard
|
||||
if (location != GHOSTTY_CLIPBOARD_STANDARD) { return }
|
||||
|
||||
guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
|
||||
if !confirm {
|
||||
let pb = NSPasteboard.general
|
||||
pb.declareTypes([.string], owner: nil)
|
||||
pb.setString(valueStr, forType: .string)
|
||||
return
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.confirmClipboard,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.ConfirmClipboardStrKey: valueStr,
|
||||
Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func openConfig(_ userdata: UnsafeMutableRawPointer?) {
|
||||
ghostty_config_open();
|
||||
}
|
||||
|
||||
static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? {
|
||||
let newConfig = Config()
|
||||
guard newConfig.loaded else {
|
||||
AppDelegate.logger.warning("failed to reload configuration")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Assign the new config. This will automatically free the old config.
|
||||
// It is safe to free the old config from within this function call.
|
||||
let state = Unmanaged<AppState>.fromOpaque(userdata!).takeUnretainedValue()
|
||||
state.config = newConfig
|
||||
|
||||
// If we have a delegate, notify.
|
||||
if let delegate = state.delegate {
|
||||
delegate.configDidReload(state)
|
||||
}
|
||||
|
||||
return newConfig.config
|
||||
}
|
||||
|
||||
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {
|
||||
let state = Unmanaged<AppState>.fromOpaque(userdata!).takeUnretainedValue()
|
||||
|
||||
// Wakeup can be called from any thread so we schedule the app tick
|
||||
// from the main thread. There is probably some improvements we can make
|
||||
// to coalesce multiple ticks but I don't think it matters from a performance
|
||||
// standpoint since we don't do this much.
|
||||
DispatchQueue.main.async { state.appTick() }
|
||||
}
|
||||
|
||||
static func renderInspector(_ userdata: UnsafeMutableRawPointer?) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.inspectorNeedsDisplay,
|
||||
object: surface
|
||||
)
|
||||
}
|
||||
|
||||
static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
guard let titleStr = String(cString: title!, encoding: .utf8) else { return }
|
||||
DispatchQueue.main.async {
|
||||
surfaceView.title = titleStr
|
||||
}
|
||||
}
|
||||
|
||||
static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
surfaceView.setCursorShape(shape)
|
||||
}
|
||||
|
||||
static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
surfaceView.setCursorVisibility(visible)
|
||||
}
|
||||
|
||||
static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyToggleFullscreen,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.NonNativeFullscreenKey: nonNativeFullscreen,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {
|
||||
// We need a window to set the frame
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
surfaceView.initialSize = NSMakeSize(Double(width), Double(height))
|
||||
}
|
||||
|
||||
static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
let backingSize = NSSize(width: Double(width), height: Double(height))
|
||||
surfaceView.cellSize = surfaceView.convertFromBacking(backingSize)
|
||||
}
|
||||
|
||||
static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?, body: UnsafePointer<CChar>?) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
guard let title = String(cString: title!, encoding: .utf8) else { return }
|
||||
guard let body = String(cString: body!, encoding: .utf8) else { return }
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.requestAuthorization(options: [.alert, .sound]) { _, error in
|
||||
if let error = error {
|
||||
AppDelegate.logger.error("Error while requesting notification authorization: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
center.getNotificationSettings() { settings in
|
||||
guard settings.authorizationStatus == .authorized else { return }
|
||||
surfaceView.showUserNotification(title: title, body: body)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user
|
||||
func handleUserNotification(response: UNNotificationResponse) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
guard let uuidString = userInfo["surface"] as? String,
|
||||
let uuid = UUID(uuidString: uuidString),
|
||||
let surface = delegate?.findSurface(forUUID: uuid) else { return }
|
||||
|
||||
switch (response.actionIdentifier) {
|
||||
case UNNotificationDefaultActionIdentifier, Ghostty.userNotificationActionShow:
|
||||
// The user clicked on a notification
|
||||
surface.handleUserNotification(notification: response.notification, focus: true)
|
||||
case UNNotificationDismissActionIdentifier:
|
||||
// The user dismissed the notification
|
||||
surface.handleUserNotification(notification: response.notification, focus: false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if a given notification should be presented to the user when Ghostty is running in the foreground.
|
||||
func shouldPresentNotification(notification: UNNotification) -> Bool {
|
||||
let userInfo = notification.request.content.userInfo
|
||||
guard let uuidString = userInfo["surface"] as? String,
|
||||
let uuid = UUID(uuidString: uuidString),
|
||||
let surface = delegate?.findSurface(forUUID: uuid),
|
||||
let window = surface.window else { return false }
|
||||
return !window.isKeyWindow || !surface.focused
|
||||
}
|
||||
|
||||
static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
|
||||
guard let appState = self.appState(fromView: surface) else { return }
|
||||
guard appState.config.windowDecorations else {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Tabs are disabled"
|
||||
alert.informativeText = "Enable window decorations to use tabs"
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.alertStyle = .warning
|
||||
_ = alert.runModal()
|
||||
return
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyNewTab,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyNewWindow,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(name: Notification.didControlInspector, object: surface, userInfo: [
|
||||
"mode": mode,
|
||||
])
|
||||
}
|
||||
|
||||
/// Returns the GhosttyState from the given userdata value.
|
||||
static private func appState(fromView view: SurfaceView) -> AppState? {
|
||||
guard let surface = view.surface else { return nil }
|
||||
guard let app = ghostty_surface_app(surface) else { return nil }
|
||||
guard let app_ud = ghostty_app_userdata(app) else { return nil }
|
||||
return Unmanaged<AppState>.fromOpaque(app_ud).takeUnretainedValue()
|
||||
}
|
||||
|
||||
/// Returns the surface view from the userdata.
|
||||
static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView {
|
||||
return Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,18 @@
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import GhosttyKit
|
||||
|
||||
protocol GhosttyAppDelegate: AnyObject {
|
||||
/// Called when the configuration did finish reloading.
|
||||
func configDidReload(_ app: Ghostty.App)
|
||||
|
||||
#if os(macOS)
|
||||
/// Called when a callback needs access to a specific surface. This should return nil
|
||||
/// when the surface is no longer valid.
|
||||
func findSurface(forUUID uuid: UUID) -> Ghostty.SurfaceView?
|
||||
#endif
|
||||
}
|
||||
|
||||
extension Ghostty {
|
||||
// IMPORTANT: THIS IS NOT DONE.
|
||||
// This is a refactor/redo of Ghostty.AppState so that it supports both macOS and iOS
|
||||
@ -9,6 +21,9 @@ extension Ghostty {
|
||||
case loading, error, ready
|
||||
}
|
||||
|
||||
/// Optional delegate
|
||||
weak var delegate: GhosttyAppDelegate?
|
||||
|
||||
/// The readiness value of the state.
|
||||
@Published var readiness: Readiness = .loading
|
||||
|
||||
@ -26,6 +41,12 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if we need to confirm before quitting.
|
||||
var needsConfirmQuit: Bool {
|
||||
guard let app = app else { return false }
|
||||
return ghostty_app_needs_confirm_quit(app)
|
||||
}
|
||||
|
||||
init() {
|
||||
// Initialize ghostty global state. This happens once per process.
|
||||
if ghostty_init() != GHOSTTY_SUCCESS {
|
||||
@ -108,7 +129,119 @@ extension Ghostty {
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: Ghostty Callbacks
|
||||
// MARK: App Operations
|
||||
|
||||
func appTick() {
|
||||
guard let app = self.app else { return }
|
||||
|
||||
// Tick our app, which lets us know if we want to quit
|
||||
let exit = ghostty_app_tick(app)
|
||||
if (!exit) { return }
|
||||
|
||||
// On iOS, applications do not terminate programmatically like they do
|
||||
// on macOS. On iOS, applications are only terminated when a user physically
|
||||
// closes the application (i.e. going to the home screen). If we request
|
||||
// exit on iOS we ignore it.
|
||||
#if os(iOS)
|
||||
logger.info("quit request received, ignoring on iOS")
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
// We want to quit, start that process
|
||||
NSApplication.shared.terminate(nil)
|
||||
#endif
|
||||
}
|
||||
|
||||
func openConfig() {
|
||||
guard let app = self.app else { return }
|
||||
ghostty_app_open_config(app)
|
||||
}
|
||||
|
||||
func reloadConfig() {
|
||||
guard let app = self.app else { return }
|
||||
ghostty_app_reload_config(app)
|
||||
}
|
||||
|
||||
/// Request that the given surface is closed. This will trigger the full normal surface close event
|
||||
/// cycle which will call our close surface callback.
|
||||
func requestClose(surface: ghostty_surface_t) {
|
||||
ghostty_surface_request_close(surface)
|
||||
}
|
||||
|
||||
func newTab(surface: ghostty_surface_t) {
|
||||
let action = "new_tab"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
func newWindow(surface: ghostty_surface_t) {
|
||||
let action = "new_window"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
func split(surface: ghostty_surface_t, direction: ghostty_split_direction_e) {
|
||||
ghostty_surface_split(surface, direction)
|
||||
}
|
||||
|
||||
func splitMoveFocus(surface: ghostty_surface_t, direction: SplitFocusDirection) {
|
||||
ghostty_surface_split_focus(surface, direction.toNative())
|
||||
}
|
||||
|
||||
func splitResize(surface: ghostty_surface_t, direction: SplitResizeDirection, amount: UInt16) {
|
||||
ghostty_surface_split_resize(surface, direction.toNative(), amount)
|
||||
}
|
||||
|
||||
func splitEqualize(surface: ghostty_surface_t) {
|
||||
ghostty_surface_split_equalize(surface)
|
||||
}
|
||||
|
||||
func splitToggleZoom(surface: ghostty_surface_t) {
|
||||
let action = "toggle_split_zoom"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
func toggleFullscreen(surface: ghostty_surface_t) {
|
||||
let action = "toggle_fullscreen"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
enum FontSizeModification {
|
||||
case increase(Int)
|
||||
case decrease(Int)
|
||||
case reset
|
||||
}
|
||||
|
||||
func changeFontSize(surface: ghostty_surface_t, _ change: FontSizeModification) {
|
||||
let action: String
|
||||
switch change {
|
||||
case .increase(let amount):
|
||||
action = "increase_font_size:\(amount)"
|
||||
case .decrease(let amount):
|
||||
action = "decrease_font_size:\(amount)"
|
||||
case .reset:
|
||||
action = "reset_font_size"
|
||||
}
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
func toggleTerminalInspector(surface: ghostty_surface_t) {
|
||||
let action = "inspector:toggle"
|
||||
if (!ghostty_surface_binding_action(surface, action, UInt(action.count))) {
|
||||
logger.warning("action failed action=\(action)")
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// MARK: Ghostty Callbacks (iOS)
|
||||
|
||||
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {}
|
||||
static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? { return nil }
|
||||
@ -156,5 +289,342 @@ extension Ghostty {
|
||||
static func renderInspector(_ userdata: UnsafeMutableRawPointer?) {}
|
||||
static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {}
|
||||
static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?, body: UnsafePointer<CChar>?) {}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
|
||||
// MARK: Notifications
|
||||
|
||||
// Called when the selected keyboard changes. We have to notify Ghostty so that
|
||||
// it can reload the keyboard mapping for input.
|
||||
@objc private func keyboardSelectionDidChange(notification: NSNotification) {
|
||||
guard let app = self.app else { return }
|
||||
ghostty_app_keyboard_changed(app)
|
||||
}
|
||||
|
||||
// MARK: Ghostty Callbacks (macOS)
|
||||
|
||||
static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e, config: ghostty_surface_config_s) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(name: Notification.ghosttyNewSplit, object: surface, userInfo: [
|
||||
"direction": direction,
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config),
|
||||
])
|
||||
}
|
||||
|
||||
static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface, userInfo: [
|
||||
"process_alive": processAlive,
|
||||
])
|
||||
}
|
||||
|
||||
static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyFocusSplit,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.SplitDirectionKey: splitDirection,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
guard let resizeDirection = SplitResizeDirection.from(direction: direction) else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didResizeSplit,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.ResizeSplitDirectionKey: resizeDirection,
|
||||
Notification.ResizeSplitAmountKey: amount,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func equalizeSplits(_ userdata: UnsafeMutableRawPointer?) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(name: Notification.didEqualizeSplits, object: surface)
|
||||
}
|
||||
|
||||
static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.didToggleSplitZoom,
|
||||
object: surface
|
||||
)
|
||||
}
|
||||
|
||||
static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyGotoTab,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.GotoTabKey: n,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) {
|
||||
// If we don't even have a surface, something went terrible wrong so we have
|
||||
// to leak "state".
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
guard let surface = surfaceView.surface else { return }
|
||||
|
||||
// We only support the standard clipboard
|
||||
if (location != GHOSTTY_CLIPBOARD_STANDARD) {
|
||||
return completeClipboardRequest(surface, data: "", state: state)
|
||||
}
|
||||
|
||||
// Get our string
|
||||
let str = NSPasteboard.general.string(forType: .string) ?? ""
|
||||
completeClipboardRequest(surface, data: str, state: state)
|
||||
}
|
||||
|
||||
static func confirmReadClipboard(
|
||||
_ userdata: UnsafeMutableRawPointer?,
|
||||
string: UnsafePointer<CChar>?,
|
||||
state: UnsafeMutableRawPointer?,
|
||||
request: ghostty_clipboard_request_e
|
||||
) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
|
||||
guard let request = Ghostty.ClipboardRequest.from(request: request) else { return }
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.confirmClipboard,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.ConfirmClipboardStrKey: valueStr,
|
||||
Notification.ConfirmClipboardStateKey: state as Any,
|
||||
Notification.ConfirmClipboardRequestKey: request,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func completeClipboardRequest(
|
||||
_ surface: ghostty_surface_t,
|
||||
data: String,
|
||||
state: UnsafeMutableRawPointer?,
|
||||
confirmed: Bool = false
|
||||
) {
|
||||
data.withCString { ptr in
|
||||
ghostty_surface_complete_clipboard_request(surface, ptr, state, confirmed)
|
||||
}
|
||||
}
|
||||
|
||||
static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?, location: ghostty_clipboard_e, confirm: Bool) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
|
||||
// We only support the standard clipboard
|
||||
if (location != GHOSTTY_CLIPBOARD_STANDARD) { return }
|
||||
|
||||
guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
|
||||
if !confirm {
|
||||
let pb = NSPasteboard.general
|
||||
pb.declareTypes([.string], owner: nil)
|
||||
pb.setString(valueStr, forType: .string)
|
||||
return
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.confirmClipboard,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.ConfirmClipboardStrKey: valueStr,
|
||||
Notification.ConfirmClipboardRequestKey: Ghostty.ClipboardRequest.osc_52_write,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func openConfig(_ userdata: UnsafeMutableRawPointer?) {
|
||||
ghostty_config_open();
|
||||
}
|
||||
|
||||
static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? {
|
||||
let newConfig = Config()
|
||||
guard newConfig.loaded else {
|
||||
AppDelegate.logger.warning("failed to reload configuration")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Assign the new config. This will automatically free the old config.
|
||||
// It is safe to free the old config from within this function call.
|
||||
let state = Unmanaged<Self>.fromOpaque(userdata!).takeUnretainedValue()
|
||||
state.config = newConfig
|
||||
|
||||
// If we have a delegate, notify.
|
||||
if let delegate = state.delegate {
|
||||
delegate.configDidReload(state)
|
||||
}
|
||||
|
||||
return newConfig.config
|
||||
}
|
||||
|
||||
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {
|
||||
let state = Unmanaged<App>.fromOpaque(userdata!).takeUnretainedValue()
|
||||
|
||||
// Wakeup can be called from any thread so we schedule the app tick
|
||||
// from the main thread. There is probably some improvements we can make
|
||||
// to coalesce multiple ticks but I don't think it matters from a performance
|
||||
// standpoint since we don't do this much.
|
||||
DispatchQueue.main.async { state.appTick() }
|
||||
}
|
||||
|
||||
static func renderInspector(_ userdata: UnsafeMutableRawPointer?) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.inspectorNeedsDisplay,
|
||||
object: surface
|
||||
)
|
||||
}
|
||||
|
||||
static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
guard let titleStr = String(cString: title!, encoding: .utf8) else { return }
|
||||
DispatchQueue.main.async {
|
||||
surfaceView.title = titleStr
|
||||
}
|
||||
}
|
||||
|
||||
static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
surfaceView.setCursorShape(shape)
|
||||
}
|
||||
|
||||
static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
surfaceView.setCursorVisibility(visible)
|
||||
}
|
||||
|
||||
static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyToggleFullscreen,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.NonNativeFullscreenKey: nonNativeFullscreen,
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {
|
||||
// We need a window to set the frame
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
surfaceView.initialSize = NSMakeSize(Double(width), Double(height))
|
||||
}
|
||||
|
||||
static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
let backingSize = NSSize(width: Double(width), height: Double(height))
|
||||
surfaceView.cellSize = surfaceView.convertFromBacking(backingSize)
|
||||
}
|
||||
|
||||
static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?, body: UnsafePointer<CChar>?) {
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
guard let title = String(cString: title!, encoding: .utf8) else { return }
|
||||
guard let body = String(cString: body!, encoding: .utf8) else { return }
|
||||
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.requestAuthorization(options: [.alert, .sound]) { _, error in
|
||||
if let error = error {
|
||||
AppDelegate.logger.error("Error while requesting notification authorization: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
center.getNotificationSettings() { settings in
|
||||
guard settings.authorizationStatus == .authorized else { return }
|
||||
surfaceView.showUserNotification(title: title, body: body)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user
|
||||
func handleUserNotification(response: UNNotificationResponse) {
|
||||
let userInfo = response.notification.request.content.userInfo
|
||||
guard let uuidString = userInfo["surface"] as? String,
|
||||
let uuid = UUID(uuidString: uuidString),
|
||||
let surface = delegate?.findSurface(forUUID: uuid) else { return }
|
||||
|
||||
switch (response.actionIdentifier) {
|
||||
case UNNotificationDefaultActionIdentifier, Ghostty.userNotificationActionShow:
|
||||
// The user clicked on a notification
|
||||
surface.handleUserNotification(notification: response.notification, focus: true)
|
||||
case UNNotificationDismissActionIdentifier:
|
||||
// The user dismissed the notification
|
||||
surface.handleUserNotification(notification: response.notification, focus: false)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Determine if a given notification should be presented to the user when Ghostty is running in the foreground.
|
||||
func shouldPresentNotification(notification: UNNotification) -> Bool {
|
||||
let userInfo = notification.request.content.userInfo
|
||||
guard let uuidString = userInfo["surface"] as? String,
|
||||
let uuid = UUID(uuidString: uuidString),
|
||||
let surface = delegate?.findSurface(forUUID: uuid),
|
||||
let window = surface.window else { return false }
|
||||
return !window.isKeyWindow || !surface.focused
|
||||
}
|
||||
|
||||
static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
|
||||
guard let appState = self.appState(fromView: surface) else { return }
|
||||
guard appState.config.windowDecorations else {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Tabs are disabled"
|
||||
alert.informativeText = "Enable window decorations to use tabs"
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.alertStyle = .warning
|
||||
_ = alert.runModal()
|
||||
return
|
||||
}
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyNewTab,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
|
||||
NotificationCenter.default.post(
|
||||
name: Notification.ghosttyNewWindow,
|
||||
object: surface,
|
||||
userInfo: [
|
||||
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config),
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) {
|
||||
let surface = self.surfaceUserdata(from: userdata)
|
||||
NotificationCenter.default.post(name: Notification.didControlInspector, object: surface, userInfo: [
|
||||
"mode": mode,
|
||||
])
|
||||
}
|
||||
|
||||
/// Returns the GhosttyState from the given userdata value.
|
||||
static private func appState(fromView view: SurfaceView) -> App? {
|
||||
guard let surface = view.surface else { return nil }
|
||||
guard let app = ghostty_surface_app(surface) else { return nil }
|
||||
guard let app_ud = ghostty_app_userdata(app) else { return nil }
|
||||
return Unmanaged<App>.fromOpaque(app_ud).takeUnretainedValue()
|
||||
}
|
||||
|
||||
/// Returns the surface view from the userdata.
|
||||
static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView {
|
||||
return Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
|
||||
}
|
||||
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,26 @@ struct Ghostty {
|
||||
static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show"
|
||||
}
|
||||
|
||||
// MARK: Build Info
|
||||
|
||||
extension Ghostty {
|
||||
struct Info {
|
||||
var mode: ghostty_build_mode_e
|
||||
var version: String
|
||||
}
|
||||
|
||||
static var info: Info {
|
||||
let raw = ghostty_info()
|
||||
let version = NSString(
|
||||
bytes: raw.version,
|
||||
length: Int(raw.version_len),
|
||||
encoding: NSUTF8StringEncoding
|
||||
) ?? "unknown"
|
||||
|
||||
return Info(mode: raw.build_mode, version: String(version))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Surface Notifications
|
||||
|
||||
extension Ghostty {
|
||||
|
@ -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.AppState
|
||||
@EnvironmentObject private var ghostty: Ghostty.App
|
||||
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String?
|
||||
|
||||
var body: some View {
|
||||
@ -49,7 +49,7 @@ extension Ghostty {
|
||||
// Maintain whether our window has focus (is key) or not
|
||||
@State private var windowFocus: Bool = true
|
||||
|
||||
@EnvironmentObject private var ghostty: Ghostty.AppState
|
||||
@EnvironmentObject private var ghostty: Ghostty.App
|
||||
|
||||
// This is true if the terminal is considered "focused". The terminal is focused if
|
||||
// it is both individually focused and the containing window is key.
|
||||
|
Reference in New Issue
Block a user