mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
@ -30,6 +30,8 @@ typedef void (*ghostty_runtime_wakeup_cb)(void *);
|
|||||||
typedef void (*ghostty_runtime_set_title_cb)(void *, const char *);
|
typedef void (*ghostty_runtime_set_title_cb)(void *, const char *);
|
||||||
typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *);
|
typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *);
|
||||||
typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *);
|
typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *);
|
||||||
|
typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e);
|
||||||
|
typedef void (*ghostty_runtime_close_surface_cb)(void *);
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
void *userdata;
|
void *userdata;
|
||||||
@ -37,6 +39,8 @@ typedef struct {
|
|||||||
ghostty_runtime_set_title_cb set_title_cb;
|
ghostty_runtime_set_title_cb set_title_cb;
|
||||||
ghostty_runtime_read_clipboard_cb read_clipboard_cb;
|
ghostty_runtime_read_clipboard_cb read_clipboard_cb;
|
||||||
ghostty_runtime_write_clipboard_cb write_clipboard_cb;
|
ghostty_runtime_write_clipboard_cb write_clipboard_cb;
|
||||||
|
ghostty_runtime_new_split_cb new_split_cb;
|
||||||
|
ghostty_runtime_close_surface_cb close_surface_cb;
|
||||||
} ghostty_runtime_config_s;
|
} ghostty_runtime_config_s;
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
@ -45,6 +49,11 @@ typedef struct {
|
|||||||
double scale_factor;
|
double scale_factor;
|
||||||
} ghostty_surface_config_s;
|
} ghostty_surface_config_s;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
GHOSTTY_SPLIT_RIGHT,
|
||||||
|
GHOSTTY_SPLIT_DOWN
|
||||||
|
} ghostty_split_direction_e;
|
||||||
|
|
||||||
typedef enum {
|
typedef enum {
|
||||||
GHOSTTY_MOUSE_RELEASE,
|
GHOSTTY_MOUSE_RELEASE,
|
||||||
GHOSTTY_MOUSE_PRESS,
|
GHOSTTY_MOUSE_PRESS,
|
||||||
@ -243,6 +252,8 @@ void ghostty_surface_mouse_button(ghostty_surface_t, ghostty_input_mouse_state_e
|
|||||||
void ghostty_surface_mouse_pos(ghostty_surface_t, double, double);
|
void ghostty_surface_mouse_pos(ghostty_surface_t, double, double);
|
||||||
void ghostty_surface_mouse_scroll(ghostty_surface_t, double, double);
|
void ghostty_surface_mouse_scroll(ghostty_surface_t, double, double);
|
||||||
void ghostty_surface_ime_point(ghostty_surface_t, double *, double *);
|
void ghostty_surface_ime_point(ghostty_surface_t, double *, double *);
|
||||||
|
void ghostty_surface_request_close(ghostty_surface_t);
|
||||||
|
void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
@ -7,28 +7,34 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
A507573E299FF33C009D7DC7 /* TerminalSurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */; };
|
|
||||||
A518502429A197C700E4CC4F /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518502329A197C700E4CC4F /* TerminalView.swift */; };
|
|
||||||
A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518502529A1A45100E4CC4F /* WindowTracker.swift */; };
|
|
||||||
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; };
|
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; };
|
||||||
A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.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 */; };
|
||||||
|
A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */; };
|
||||||
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
|
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; };
|
||||||
A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyApp.swift */; };
|
A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyApp.swift */; };
|
||||||
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
|
A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; };
|
||||||
|
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; };
|
||||||
|
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; };
|
||||||
A5D495A2299BEC7E00DD1313 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
|
A5D495A2299BEC7E00DD1313 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurfaceView.swift; sourceTree = "<group>"; };
|
|
||||||
A518502329A197C700E4CC4F /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = "<group>"; };
|
|
||||||
A518502529A1A45100E4CC4F /* WindowTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTracker.swift; sourceTree = "<group>"; };
|
|
||||||
A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
|
A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; };
|
||||||
A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.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>"; };
|
||||||
|
A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitView.swift; sourceTree = "<group>"; };
|
||||||
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||||
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
A5B30534299BEAAA0047F10C /* GhosttyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyApp.swift; sourceTree = "<group>"; };
|
A5B30534299BEAAA0047F10C /* GhosttyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhosttyApp.swift; sourceTree = "<group>"; };
|
||||||
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
|
A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = "<group>"; };
|
||||||
|
A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = "<group>"; };
|
||||||
|
A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = "<group>"; };
|
||||||
A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
|
A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
@ -48,17 +54,27 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
A5D495A0299BEC2200DD1313 /* Preview Content */,
|
A5D495A0299BEC2200DD1313 /* Preview Content */,
|
||||||
|
A5CEAFDA29B8005900646FDA /* SplitView */,
|
||||||
|
A55B7BB429B6F4410055DE60 /* Ghostty */,
|
||||||
A5B30534299BEAAA0047F10C /* GhosttyApp.swift */,
|
A5B30534299BEAAA0047F10C /* GhosttyApp.swift */,
|
||||||
A535B9D9299C569B0017E2E4 /* ErrorView.swift */,
|
A535B9D9299C569B0017E2E4 /* ErrorView.swift */,
|
||||||
A55685DF29A03A9F004303CE /* AppError.swift */,
|
A55685DF29A03A9F004303CE /* AppError.swift */,
|
||||||
A59444F629A2ED5200725BBA /* SettingsView.swift */,
|
A59444F629A2ED5200725BBA /* SettingsView.swift */,
|
||||||
A518502329A197C700E4CC4F /* TerminalView.swift */,
|
|
||||||
A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */,
|
|
||||||
A518502529A1A45100E4CC4F /* WindowTracker.swift */,
|
|
||||||
);
|
);
|
||||||
path = Sources;
|
path = Sources;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A55B7BB429B6F4410055DE60 /* Ghostty */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A55B7BB729B6F53A0055DE60 /* Package.swift */,
|
||||||
|
A55B7BB529B6F47F0055DE60 /* AppState.swift */,
|
||||||
|
A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */,
|
||||||
|
A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */,
|
||||||
|
);
|
||||||
|
path = Ghostty;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A5B30528299BEAAA0047F10C = {
|
A5B30528299BEAAA0047F10C = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -78,6 +94,15 @@
|
|||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
A5CEAFDA29B8005900646FDA /* SplitView */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A5CEAFDB29B8009000646FDA /* SplitView.swift */,
|
||||||
|
A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */,
|
||||||
|
);
|
||||||
|
path = SplitView;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
A5D495A0299BEC2200DD1313 /* Preview Content */ = {
|
A5D495A0299BEC2200DD1313 /* Preview Content */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -162,13 +187,16 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */,
|
||||||
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
|
A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */,
|
||||||
A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */,
|
A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
|
||||||
A518502429A197C700E4CC4F /* TerminalView.swift in Sources */,
|
A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */,
|
||||||
|
A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */,
|
||||||
|
A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */,
|
||||||
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
|
A55685E029A03A9F004303CE /* AppError.swift in Sources */,
|
||||||
A507573E299FF33C009D7DC7 /* TerminalSurfaceView.swift in Sources */,
|
|
||||||
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
|
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
|
||||||
A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */,
|
A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */,
|
||||||
|
A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
177
macos/Sources/Ghostty/AppState.swift
Normal file
177
macos/Sources/Ghostty/AppState.swift
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import GhosttyKit
|
||||||
|
|
||||||
|
extension Ghostty {
|
||||||
|
enum AppReadiness {
|
||||||
|
case loading, error, ready
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
|
||||||
|
/// The ghostty global configuration.
|
||||||
|
var config: ghostty_config_t? = nil
|
||||||
|
|
||||||
|
/// 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...
|
||||||
|
var app: ghostty_app_t? = nil
|
||||||
|
|
||||||
|
/// Cached clipboard string for `read_clipboard` callback.
|
||||||
|
private var cached_clipboard_string: String? = nil
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Initialize ghostty global state. This happens once per process.
|
||||||
|
guard ghostty_init() == GHOSTTY_SUCCESS else {
|
||||||
|
GhosttyApp.logger.critical("ghostty_init failed")
|
||||||
|
readiness = .error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the global configuration.
|
||||||
|
guard let cfg = ghostty_config_new() else {
|
||||||
|
GhosttyApp.logger.critical("ghostty_config_new failed")
|
||||||
|
readiness = .error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.config = cfg;
|
||||||
|
|
||||||
|
// Load our configuration files from the home directory.
|
||||||
|
ghostty_config_load_default_files(cfg);
|
||||||
|
ghostty_config_load_recursive_files(cfg);
|
||||||
|
|
||||||
|
// TODO: we'd probably do some config loading here... for now we'd
|
||||||
|
// have to do this synchronously. When we support config updating we can do
|
||||||
|
// this async and update later.
|
||||||
|
|
||||||
|
// Finalize will make our defaults available.
|
||||||
|
ghostty_config_finalize(cfg)
|
||||||
|
|
||||||
|
// 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(),
|
||||||
|
wakeup_cb: { userdata in AppState.wakeup(userdata) },
|
||||||
|
set_title_cb: { userdata, title in AppState.setTitle(userdata, title: title) },
|
||||||
|
read_clipboard_cb: { userdata in AppState.readClipboard(userdata) },
|
||||||
|
write_clipboard_cb: { userdata, str in AppState.writeClipboard(userdata, string: str) },
|
||||||
|
new_split_cb: { userdata, direction in AppState.newSplit(userdata, direction: ghostty_split_direction_e(UInt32(direction))) },
|
||||||
|
close_surface_cb: { userdata in AppState.closeSurface(userdata) }
|
||||||
|
)
|
||||||
|
|
||||||
|
// Create the ghostty app.
|
||||||
|
guard let app = ghostty_app_new(&runtime_cfg, cfg) else {
|
||||||
|
GhosttyApp.logger.critical("ghostty_app_new failed")
|
||||||
|
readiness = .error
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
self.readiness = .ready
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
ghostty_app_free(app)
|
||||||
|
ghostty_config_free(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appTick() {
|
||||||
|
guard let app = self.app else { return }
|
||||||
|
ghostty_app_tick(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 split(surface: ghostty_surface_t, direction: ghostty_split_direction_e) {
|
||||||
|
ghostty_surface_split(surface, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Ghostty Callbacks
|
||||||
|
|
||||||
|
static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e) {
|
||||||
|
guard let surface = self.surfaceUserdata(from: userdata) else { return }
|
||||||
|
NotificationCenter.default.post(name: Notification.ghosttyNewSplit, object: surface, userInfo: [
|
||||||
|
"direction": direction,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
static func closeSurface(_ userdata: UnsafeMutableRawPointer?) {
|
||||||
|
guard let surface = self.surfaceUserdata(from: userdata) else { return }
|
||||||
|
NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func readClipboard(_ userdata: UnsafeMutableRawPointer?) -> UnsafePointer<CChar>? {
|
||||||
|
guard let appState = self.appState(fromSurface: userdata) else { return nil }
|
||||||
|
guard let str = NSPasteboard.general.string(forType: .string) else { return nil }
|
||||||
|
|
||||||
|
// Ghostty requires we cache the string because the pointer we return has to remain
|
||||||
|
// stable until the next call to readClipboard.
|
||||||
|
appState.cached_clipboard_string = str
|
||||||
|
return (str as NSString).utf8String
|
||||||
|
}
|
||||||
|
|
||||||
|
static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?) {
|
||||||
|
guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
|
||||||
|
let pb = NSPasteboard.general
|
||||||
|
pb.declareTypes([.string], owner: nil)
|
||||||
|
pb.setString(valueStr, forType: .string)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?) {
|
||||||
|
let surfaceView = Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
|
||||||
|
guard let titleStr = String(cString: title!, encoding: .utf8) else { return }
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
surfaceView.title = titleStr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the GhosttyState from the given userdata value.
|
||||||
|
static private func appState(fromSurface userdata: UnsafeMutableRawPointer?) -> AppState? {
|
||||||
|
let surfaceView = Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
|
||||||
|
guard let surface = surfaceView.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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: AppState Environment Keys
|
||||||
|
|
||||||
|
private struct GhosttyAppKey: EnvironmentKey {
|
||||||
|
static let defaultValue: ghostty_app_t? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var ghosttyApp: ghostty_app_t? {
|
||||||
|
get { self[GhosttyAppKey.self] }
|
||||||
|
set { self[GhosttyAppKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func ghosttyApp(_ app: ghostty_app_t?) -> some View {
|
||||||
|
environment(\.ghosttyApp, app)
|
||||||
|
}
|
||||||
|
}
|
245
macos/Sources/Ghostty/Ghostty.SplitView.swift
Normal file
245
macos/Sources/Ghostty/Ghostty.SplitView.swift
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import GhosttyKit
|
||||||
|
|
||||||
|
extension Ghostty {
|
||||||
|
/// A spittable terminal view is one where the terminal allows for "splits" (vertical and horizontal) within the
|
||||||
|
/// view. The terminal starts in the unsplit state (a plain ol' TerminalView) but responds to changes to the
|
||||||
|
/// split direction by splitting the terminal.
|
||||||
|
struct TerminalSplit: View {
|
||||||
|
@Environment(\.ghosttyApp) private var app
|
||||||
|
let onClose: (() -> Void)?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let app = app {
|
||||||
|
TerminalSplitRoot(app: app, onClose: onClose)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This enum represents the possible states that a node in the split tree can be in. It is either:
|
||||||
|
///
|
||||||
|
/// - noSplit - This is an unsplit, single pane. This contains only a "leaf" which has a single
|
||||||
|
/// terminal surface to render.
|
||||||
|
/// - horizontal/vertical - This is split into the horizontal or vertical direction. This contains a
|
||||||
|
/// "container" which has a recursive top/left SplitNode and bottom/right SplitNode. These
|
||||||
|
/// values can further be split infinitely.
|
||||||
|
///
|
||||||
|
enum SplitNode {
|
||||||
|
case noSplit(Leaf)
|
||||||
|
case horizontal(Container)
|
||||||
|
case vertical(Container)
|
||||||
|
|
||||||
|
/// Returns the view that would prefer receiving focus in this tree. This is always the
|
||||||
|
/// top-left-most view. This is used when creating a split or closing a split to find the
|
||||||
|
/// next view to send focus to.
|
||||||
|
func preferredFocus() -> SurfaceView {
|
||||||
|
switch (self) {
|
||||||
|
case .noSplit(let leaf):
|
||||||
|
return leaf.surface
|
||||||
|
|
||||||
|
case .horizontal(let container):
|
||||||
|
return container.topLeft.preferredFocus()
|
||||||
|
|
||||||
|
case .vertical(let container):
|
||||||
|
return container.topLeft.preferredFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Leaf: ObservableObject {
|
||||||
|
let app: ghostty_app_t
|
||||||
|
@Published var surface: SurfaceView
|
||||||
|
|
||||||
|
/// Initialize a new leaf which creates a new terminal surface.
|
||||||
|
init(_ app: ghostty_app_t) {
|
||||||
|
self.app = app
|
||||||
|
self.surface = SurfaceView(app)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Container: ObservableObject {
|
||||||
|
let app: ghostty_app_t
|
||||||
|
@Published var topLeft: SplitNode
|
||||||
|
@Published var bottomRight: SplitNode
|
||||||
|
|
||||||
|
/// A container is always initialized from some prior leaf because a split has to originate
|
||||||
|
/// from a non-split value. When initializing, we inherit the leaf's surface and then
|
||||||
|
/// initialize a new surface for the new pane.
|
||||||
|
init(from: Leaf) {
|
||||||
|
self.app = from.app
|
||||||
|
|
||||||
|
// Initially, both topLeft and bottomRight are in the "nosplit"
|
||||||
|
// state since this is a new split.
|
||||||
|
self.topLeft = .noSplit(from)
|
||||||
|
self.bottomRight = .noSplit(.init(app))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The root of a split tree. This sets up the initial SplitNode state and renders. There is only ever
|
||||||
|
/// one of these in a split tree.
|
||||||
|
private struct TerminalSplitRoot: View {
|
||||||
|
@State private var node: SplitNode
|
||||||
|
@State private var requestClose: Bool = false
|
||||||
|
let onClose: (() -> Void)?
|
||||||
|
|
||||||
|
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String?
|
||||||
|
|
||||||
|
init(app: ghostty_app_t, onClose: (() ->Void)? = nil) {
|
||||||
|
self.onClose = onClose
|
||||||
|
_node = State(wrappedValue: SplitNode.noSplit(.init(app)))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
switch (node) {
|
||||||
|
case .noSplit(let leaf):
|
||||||
|
TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $requestClose)
|
||||||
|
.onChange(of: requestClose) { value in
|
||||||
|
guard value else { return }
|
||||||
|
guard let onClose = self.onClose else { return }
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
case .horizontal(let container):
|
||||||
|
TerminalSplitContainer(direction: .horizontal, node: $node, container: container)
|
||||||
|
|
||||||
|
case .vertical(let container):
|
||||||
|
TerminalSplitContainer(direction: .vertical, node: $node, container: container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle(surfaceTitle ?? "Ghostty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A noSplit leaf node of a split tree.
|
||||||
|
private struct TerminalSplitLeaf: View {
|
||||||
|
/// The leaf to draw the surface for.
|
||||||
|
let leaf: SplitNode.Leaf
|
||||||
|
|
||||||
|
/// The SplitNode that the leaf belongs to.
|
||||||
|
@Binding var node: SplitNode
|
||||||
|
|
||||||
|
/// This will be set to true when the split requests that is become closed.
|
||||||
|
@Binding var requestClose: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
let pub = NotificationCenter.default.publisher(for: Notification.ghosttyNewSplit, object: leaf.surface)
|
||||||
|
let pubClose = NotificationCenter.default.publisher(for: Notification.ghosttyCloseSurface, object: leaf.surface)
|
||||||
|
SurfaceWrapper(surfaceView: leaf.surface)
|
||||||
|
.onReceive(pub) { onNewSplit(notification: $0) }
|
||||||
|
.onReceive(pubClose) { _ in requestClose = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func onNewSplit(notification: SwiftUI.Notification) {
|
||||||
|
// Determine our desired direction
|
||||||
|
guard let directionAny = notification.userInfo?["direction"] else { return }
|
||||||
|
guard let direction = directionAny as? ghostty_split_direction_e else { return }
|
||||||
|
var splitDirection: SplitViewDirection
|
||||||
|
switch (direction) {
|
||||||
|
case GHOSTTY_SPLIT_RIGHT:
|
||||||
|
splitDirection = .horizontal
|
||||||
|
|
||||||
|
case GHOSTTY_SPLIT_DOWN:
|
||||||
|
splitDirection = .vertical
|
||||||
|
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup our new container since we are now split
|
||||||
|
let container = SplitNode.Container(from: leaf)
|
||||||
|
|
||||||
|
// Depending on the direction, change the parent node. This will trigger
|
||||||
|
// the parent to relayout our views.
|
||||||
|
switch (splitDirection) {
|
||||||
|
case .horizontal:
|
||||||
|
node = .horizontal(container)
|
||||||
|
case .vertical:
|
||||||
|
node = .vertical(container)
|
||||||
|
}
|
||||||
|
|
||||||
|
// See fixFocus comment, we have to run this whenever split changes.
|
||||||
|
Self.fixFocus(container.bottomRight, previous: node)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// There is a bug I can't figure out where when changing the split state, the terminal view
|
||||||
|
/// will lose focus. There has to be some nice SwiftUI-native way to fix this but I can't
|
||||||
|
/// figure it out so we're going to do this hacky thing to bring focus back to the terminal
|
||||||
|
/// that should have it.
|
||||||
|
fileprivate static func fixFocus(_ target: SplitNode, previous: SplitNode) {
|
||||||
|
let view = target.preferredFocus()
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
// If the callback runs before the surface is attached to a view
|
||||||
|
// then the window will be nil. We just reschedule in that case.
|
||||||
|
guard let window = view.window else {
|
||||||
|
self.fixFocus(target, previous: previous)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.makeFirstResponder(view)
|
||||||
|
|
||||||
|
// If we had a previously focused node and its not where we're sending
|
||||||
|
// focus, make sure that we explicitly tell it to lose focus. In theory
|
||||||
|
// we should NOT have to do this but the focus callback isn't getting
|
||||||
|
// called for some reason.
|
||||||
|
let previous = previous.preferredFocus()
|
||||||
|
if previous != view {
|
||||||
|
_ = previous.resignFirstResponder()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This represents a split view that is in the horizontal or vertical split state.
|
||||||
|
private struct TerminalSplitContainer: View {
|
||||||
|
let direction: SplitViewDirection
|
||||||
|
@Binding var node: SplitNode
|
||||||
|
@StateObject var container: SplitNode.Container
|
||||||
|
|
||||||
|
@State private var closeTopLeft: Bool = false
|
||||||
|
@State private var closeBottomRight: Bool = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
SplitView(direction, left: {
|
||||||
|
TerminalSplitNested(node: $container.topLeft, requestClose: $closeTopLeft)
|
||||||
|
.onChange(of: closeTopLeft) { value in
|
||||||
|
guard value else { return }
|
||||||
|
|
||||||
|
// When closing the topLeft, our parent becomes the bottomRight.
|
||||||
|
node = container.bottomRight
|
||||||
|
TerminalSplitLeaf.fixFocus(node, previous: container.topLeft)
|
||||||
|
}
|
||||||
|
}, right: {
|
||||||
|
TerminalSplitNested(node: $container.bottomRight, requestClose: $closeBottomRight)
|
||||||
|
.onChange(of: closeBottomRight) { value in
|
||||||
|
guard value else { return }
|
||||||
|
|
||||||
|
// When closing the bottomRight, our parent becomes the topLeft.
|
||||||
|
node = container.topLeft
|
||||||
|
TerminalSplitLeaf.fixFocus(node, previous: container.bottomRight)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// This is like TerminalSplitRoot, but... not the root. This renders a SplitNode in any state but
|
||||||
|
/// requires there be a binding to the parent node.
|
||||||
|
private struct TerminalSplitNested: View {
|
||||||
|
@Binding var node: SplitNode
|
||||||
|
@Binding var requestClose: Bool
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch (node) {
|
||||||
|
case .noSplit(let leaf):
|
||||||
|
TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $requestClose)
|
||||||
|
|
||||||
|
case .horizontal(let container):
|
||||||
|
TerminalSplitContainer(direction: .horizontal, node: $node, container: container)
|
||||||
|
|
||||||
|
case .vertical(let container):
|
||||||
|
TerminalSplitContainer(direction: .vertical, node: $node, container: container)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
4
macos/Sources/Ghostty/Package.swift
Normal file
4
macos/Sources/Ghostty/Package.swift
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
struct Ghostty {
|
||||||
|
// All the notifications that will be emitted will be put here.
|
||||||
|
struct Notification {}
|
||||||
|
}
|
568
macos/Sources/Ghostty/SurfaceView.swift
Normal file
568
macos/Sources/Ghostty/SurfaceView.swift
Normal file
@ -0,0 +1,568 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import GhosttyKit
|
||||||
|
|
||||||
|
extension Ghostty {
|
||||||
|
/// Render a terminal for the active app in the environment.
|
||||||
|
struct Terminal: View {
|
||||||
|
@Environment(\.ghosttyApp) private var app
|
||||||
|
@FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String?
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if let app = self.app {
|
||||||
|
SurfaceForApp(app) { surfaceView in
|
||||||
|
SurfaceWrapper(surfaceView: surfaceView)
|
||||||
|
}
|
||||||
|
.navigationTitle(surfaceTitle ?? "Ghostty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Yields a SurfaceView for a ghostty app that can then be used however you want.
|
||||||
|
struct SurfaceForApp<Content: View>: View {
|
||||||
|
let content: ((SurfaceView) -> Content)
|
||||||
|
|
||||||
|
@StateObject private var surfaceView: SurfaceView
|
||||||
|
|
||||||
|
init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) {
|
||||||
|
_surfaceView = StateObject(wrappedValue: SurfaceView(app))
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
content(surfaceView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
@FocusState private var surfaceFocus: Bool
|
||||||
|
|
||||||
|
// https://nilcoalescing.com/blog/DetectFocusedWindowOnMacOS/
|
||||||
|
@Environment(\.controlActiveState) var controlActiveState
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
private var hasFocus: Bool { surfaceFocus && controlActiveState == .key }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// We use a GeometryReader to get the frame bounds so that our metal surface
|
||||||
|
// is up to date. See TerminalSurfaceView for why we don't use the NSView
|
||||||
|
// resize callback.
|
||||||
|
GeometryReader { geo in
|
||||||
|
Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size)
|
||||||
|
.focused($surfaceFocus)
|
||||||
|
.focusedValue(\.ghosttySurfaceTitle, surfaceView.title)
|
||||||
|
.focusedValue(\.ghosttySurfaceView, surfaceView)
|
||||||
|
}
|
||||||
|
.ghosttySurfaceView(surfaceView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn
|
||||||
|
/// and interacted with. The word "surface" is used because a surface may represent a window, a tab,
|
||||||
|
/// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it.
|
||||||
|
///
|
||||||
|
/// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible
|
||||||
|
/// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to
|
||||||
|
/// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with.
|
||||||
|
struct Surface: NSViewRepresentable {
|
||||||
|
/// The view to render for the terminal surface.
|
||||||
|
let view: SurfaceView
|
||||||
|
|
||||||
|
/// This should be set to true wen the surface has focus. This is up to the parent because
|
||||||
|
/// focus is also defined by window focus. It is important this is set correctly since if it is
|
||||||
|
/// false then the surface will idle at almost 0% CPU.
|
||||||
|
let hasFocus: Bool
|
||||||
|
|
||||||
|
/// The size of the frame containing this view. We use this to update the the underlying
|
||||||
|
/// surface. This does not actually SET the size of our frame, this only sets the size
|
||||||
|
/// of our Metal surface for drawing.
|
||||||
|
///
|
||||||
|
/// Note: we do NOT use the NSView.resize function because SwiftUI on macOS 12
|
||||||
|
/// does not call this callback (macOS 13+ does).
|
||||||
|
///
|
||||||
|
/// The best approach is to wrap this view in a GeometryReader and pass in the geo.size.
|
||||||
|
let size: CGSize
|
||||||
|
|
||||||
|
func makeNSView(context: Context) -> SurfaceView {
|
||||||
|
// We need the view as part of the state to be created previously because
|
||||||
|
// the view is sent to the Ghostty API so that it can manipulate it
|
||||||
|
// directly since we draw on a render thread.
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateNSView(_ view: SurfaceView, context: Context) {
|
||||||
|
view.focusDidChange(hasFocus)
|
||||||
|
view.sizeDidChange(size)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The NSView implementation for a terminal surface.
|
||||||
|
class SurfaceView: NSView, NSTextInputClient, ObservableObject {
|
||||||
|
// 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 = ""
|
||||||
|
|
||||||
|
private(set) var surface: ghostty_surface_t?
|
||||||
|
var error: Error? = nil
|
||||||
|
|
||||||
|
private var markedText: NSMutableAttributedString;
|
||||||
|
|
||||||
|
// We need to support being a first responder so that we can get input events
|
||||||
|
override var acceptsFirstResponder: Bool { return true }
|
||||||
|
|
||||||
|
// I don't think we need this but this lets us know we should redraw our layer
|
||||||
|
// so we'll use that to tell ghostty to refresh.
|
||||||
|
override var wantsUpdateLayer: Bool { return true }
|
||||||
|
|
||||||
|
init(_ app: ghostty_app_t) {
|
||||||
|
self.markedText = NSMutableAttributedString()
|
||||||
|
|
||||||
|
// 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: NSMakeRect(0, 0, 800, 600))
|
||||||
|
|
||||||
|
// Setup our surface. This will also initialize all the terminal IO.
|
||||||
|
var surface_cfg = ghostty_surface_config_s(
|
||||||
|
userdata: Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
nsview: Unmanaged.passUnretained(self).toOpaque(),
|
||||||
|
scale_factor: NSScreen.main!.backingScaleFactor)
|
||||||
|
guard let surface = ghostty_surface_new(app, &surface_cfg) else {
|
||||||
|
self.error = AppError.surfaceCreateError
|
||||||
|
return
|
||||||
|
}
|
||||||
|
self.surface = surface;
|
||||||
|
|
||||||
|
// Setup our tracking area so we get mouse moved events
|
||||||
|
updateTrackingAreas()
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) is not supported for this view")
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
trackingAreas.forEach { removeTrackingArea($0) }
|
||||||
|
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
ghostty_surface_free(surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func focusDidChange(_ focused: Bool) {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
ghostty_surface_set_focus(surface, focused)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sizeDidChange(_ size: CGSize) {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
// The size represents our final size we're going for.
|
||||||
|
let scaledSize = self.convertToBacking(size)
|
||||||
|
ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resignFirstResponder() -> Bool {
|
||||||
|
let result = super.resignFirstResponder()
|
||||||
|
|
||||||
|
// We sometimes call this manually (see SplitView) as a way to force us to
|
||||||
|
// yield our focus state.
|
||||||
|
if (result) { focusDidChange(false) }
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
override func updateTrackingAreas() {
|
||||||
|
// To update our tracking area we just recreate it all.
|
||||||
|
trackingAreas.forEach { removeTrackingArea($0) }
|
||||||
|
|
||||||
|
// This tracking area is across the entire frame to notify us of mouse movements.
|
||||||
|
addTrackingArea(NSTrackingArea(
|
||||||
|
rect: frame,
|
||||||
|
options: [
|
||||||
|
.mouseEnteredAndExited,
|
||||||
|
.mouseMoved,
|
||||||
|
.inVisibleRect,
|
||||||
|
|
||||||
|
// It is possible this is incorrect when we have splits. This will make
|
||||||
|
// mouse events only happen while the terminal is focused. Is that what
|
||||||
|
// we want?
|
||||||
|
.activeWhenFirstResponder,
|
||||||
|
],
|
||||||
|
owner: self,
|
||||||
|
userInfo: nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func resetCursorRects() {
|
||||||
|
discardCursorRects()
|
||||||
|
addCursorRect(frame, cursor: .iBeam)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func viewDidChangeBackingProperties() {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
|
||||||
|
// Detect our X/Y scale factor so we can update our surface
|
||||||
|
let fbFrame = self.convertToBacking(self.frame)
|
||||||
|
let xScale = fbFrame.size.width / self.frame.size.width
|
||||||
|
let yScale = fbFrame.size.height / self.frame.size.height
|
||||||
|
ghostty_surface_set_content_scale(surface, xScale, yScale)
|
||||||
|
|
||||||
|
// When our scale factor changes, so does our fb size so we send that too
|
||||||
|
ghostty_surface_set_size(surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
override func updateLayer() {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
ghostty_surface_refresh(surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDown(with event: NSEvent) {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
let mods = Self.translateFlags(event.modifierFlags)
|
||||||
|
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseUp(with event: NSEvent) {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
let mods = Self.translateFlags(event.modifierFlags)
|
||||||
|
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func rightMouseDown(with event: NSEvent) {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
let mods = Self.translateFlags(event.modifierFlags)
|
||||||
|
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func rightMouseUp(with event: NSEvent) {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
let mods = Self.translateFlags(event.modifierFlags)
|
||||||
|
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseMoved(with event: NSEvent) {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
|
||||||
|
// Convert window position to view position. Note (0, 0) is bottom left.
|
||||||
|
let pos = self.convert(event.locationInWindow, from: nil)
|
||||||
|
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override func mouseDragged(with event: NSEvent) {
|
||||||
|
self.mouseMoved(with: event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func scrollWheel(with event: NSEvent) {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
|
||||||
|
var x = event.scrollingDeltaX
|
||||||
|
var y = event.scrollingDeltaY
|
||||||
|
if event.hasPreciseScrollingDeltas {
|
||||||
|
x *= 0.1
|
||||||
|
y *= 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
ghostty_surface_mouse_scroll(surface, x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyDown(with event: NSEvent) {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
let key = Self.keycodes[event.keyCode] ?? GHOSTTY_KEY_INVALID
|
||||||
|
let mods = Self.translateFlags(event.modifierFlags)
|
||||||
|
let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
|
||||||
|
ghostty_surface_key(surface, action, key, mods)
|
||||||
|
|
||||||
|
self.interpretKeyEvents([event])
|
||||||
|
}
|
||||||
|
|
||||||
|
override func keyUp(with event: NSEvent) {
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
let key = Self.keycodes[event.keyCode] ?? GHOSTTY_KEY_INVALID
|
||||||
|
let mods = Self.translateFlags(event.modifierFlags)
|
||||||
|
ghostty_surface_key(surface, GHOSTTY_ACTION_RELEASE, key, mods)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: NSTextInputClient
|
||||||
|
|
||||||
|
func hasMarkedText() -> Bool {
|
||||||
|
return markedText.length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func markedRange() -> NSRange {
|
||||||
|
guard markedText.length > 0 else { return NSRange() }
|
||||||
|
return NSRange(0...(markedText.length-1))
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectedRange() -> NSRange {
|
||||||
|
return NSRange()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
|
||||||
|
switch string {
|
||||||
|
case let v as NSAttributedString:
|
||||||
|
self.markedText = NSMutableAttributedString(attributedString: v)
|
||||||
|
|
||||||
|
case let v as String:
|
||||||
|
self.markedText = NSMutableAttributedString(string: v)
|
||||||
|
|
||||||
|
default:
|
||||||
|
print("unknown marked text: \(string)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func unmarkText() {
|
||||||
|
self.markedText.mutableString.setString("")
|
||||||
|
}
|
||||||
|
|
||||||
|
func validAttributesForMarkedText() -> [NSAttributedString.Key] {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func characterIndex(for point: NSPoint) -> Int {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
|
||||||
|
guard let surface = self.surface else {
|
||||||
|
return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ghostty will tell us where it thinks an IME keyboard should render.
|
||||||
|
var x: Double = 0;
|
||||||
|
var y: Double = 0;
|
||||||
|
ghostty_surface_ime_point(surface, &x, &y)
|
||||||
|
|
||||||
|
// Ghostty coordinates are in top-left (0, 0) so we have to convert to
|
||||||
|
// bottom-left since that is what UIKit expects
|
||||||
|
let rect = NSMakeRect(x, frame.size.height - y, 0, 0)
|
||||||
|
|
||||||
|
// Convert from view to screen coordinates
|
||||||
|
guard let window = self.window else { return rect }
|
||||||
|
return window.convertToScreen(rect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertText(_ string: Any, replacementRange: NSRange) {
|
||||||
|
// We must have an associated event
|
||||||
|
guard NSApp.currentEvent != nil else { return }
|
||||||
|
guard let surface = self.surface else { return }
|
||||||
|
|
||||||
|
// We want the string view of the any value
|
||||||
|
var chars = ""
|
||||||
|
switch (string) {
|
||||||
|
case let v as NSAttributedString:
|
||||||
|
chars = v.string
|
||||||
|
case let v as String:
|
||||||
|
chars = v
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for codepoint in chars.unicodeScalars {
|
||||||
|
ghostty_surface_char(surface, codepoint.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override func doCommand(by selector: Selector) {
|
||||||
|
// This currently just prevents NSBeep from interpretKeyEvents but in the future
|
||||||
|
// we may want to make some of this work.
|
||||||
|
|
||||||
|
print("SEL: \(selector)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func translateFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {
|
||||||
|
var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue
|
||||||
|
if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
|
||||||
|
if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue }
|
||||||
|
if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue }
|
||||||
|
if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue }
|
||||||
|
if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue }
|
||||||
|
|
||||||
|
return ghostty_input_mods_e(mods)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mapping of event keyCode to ghostty input key values. This is cribbed from
|
||||||
|
// glfw mostly since we started as a glfw-based app way back in the day!
|
||||||
|
static let keycodes: [UInt16 : ghostty_input_key_e] = [
|
||||||
|
0x1D: GHOSTTY_KEY_ZERO,
|
||||||
|
0x12: GHOSTTY_KEY_ONE,
|
||||||
|
0x13: GHOSTTY_KEY_TWO,
|
||||||
|
0x14: GHOSTTY_KEY_THREE,
|
||||||
|
0x15: GHOSTTY_KEY_FOUR,
|
||||||
|
0x17: GHOSTTY_KEY_FIVE,
|
||||||
|
0x16: GHOSTTY_KEY_SIX,
|
||||||
|
0x1A: GHOSTTY_KEY_SEVEN,
|
||||||
|
0x1C: GHOSTTY_KEY_EIGHT,
|
||||||
|
0x19: GHOSTTY_KEY_NINE,
|
||||||
|
0x00: GHOSTTY_KEY_A,
|
||||||
|
0x0B: GHOSTTY_KEY_B,
|
||||||
|
0x08: GHOSTTY_KEY_C,
|
||||||
|
0x02: GHOSTTY_KEY_D,
|
||||||
|
0x0E: GHOSTTY_KEY_E,
|
||||||
|
0x03: GHOSTTY_KEY_F,
|
||||||
|
0x05: GHOSTTY_KEY_G,
|
||||||
|
0x04: GHOSTTY_KEY_H,
|
||||||
|
0x22: GHOSTTY_KEY_I,
|
||||||
|
0x26: GHOSTTY_KEY_J,
|
||||||
|
0x28: GHOSTTY_KEY_K,
|
||||||
|
0x25: GHOSTTY_KEY_L,
|
||||||
|
0x2E: GHOSTTY_KEY_M,
|
||||||
|
0x2D: GHOSTTY_KEY_N,
|
||||||
|
0x1F: GHOSTTY_KEY_O,
|
||||||
|
0x23: GHOSTTY_KEY_P,
|
||||||
|
0x0C: GHOSTTY_KEY_Q,
|
||||||
|
0x0F: GHOSTTY_KEY_R,
|
||||||
|
0x01: GHOSTTY_KEY_S,
|
||||||
|
0x11: GHOSTTY_KEY_T,
|
||||||
|
0x20: GHOSTTY_KEY_U,
|
||||||
|
0x09: GHOSTTY_KEY_V,
|
||||||
|
0x0D: GHOSTTY_KEY_W,
|
||||||
|
0x07: GHOSTTY_KEY_X,
|
||||||
|
0x10: GHOSTTY_KEY_Y,
|
||||||
|
0x06: GHOSTTY_KEY_Z,
|
||||||
|
|
||||||
|
0x27: GHOSTTY_KEY_APOSTROPHE,
|
||||||
|
0x2A: GHOSTTY_KEY_BACKSLASH,
|
||||||
|
0x2B: GHOSTTY_KEY_COMMA,
|
||||||
|
0x18: GHOSTTY_KEY_EQUAL,
|
||||||
|
0x32: GHOSTTY_KEY_GRAVE_ACCENT,
|
||||||
|
0x21: GHOSTTY_KEY_LEFT_BRACKET,
|
||||||
|
0x1B: GHOSTTY_KEY_MINUS,
|
||||||
|
0x2F: GHOSTTY_KEY_PERIOD,
|
||||||
|
0x1E: GHOSTTY_KEY_RIGHT_BRACKET,
|
||||||
|
0x29: GHOSTTY_KEY_SEMICOLON,
|
||||||
|
0x2C: GHOSTTY_KEY_SLASH,
|
||||||
|
|
||||||
|
0x33: GHOSTTY_KEY_BACKSPACE,
|
||||||
|
0x39: GHOSTTY_KEY_CAPS_LOCK,
|
||||||
|
0x75: GHOSTTY_KEY_DELETE,
|
||||||
|
0x7D: GHOSTTY_KEY_DOWN,
|
||||||
|
0x77: GHOSTTY_KEY_END,
|
||||||
|
0x24: GHOSTTY_KEY_ENTER,
|
||||||
|
0x35: GHOSTTY_KEY_ESCAPE,
|
||||||
|
0x7A: GHOSTTY_KEY_F1,
|
||||||
|
0x78: GHOSTTY_KEY_F2,
|
||||||
|
0x63: GHOSTTY_KEY_F3,
|
||||||
|
0x76: GHOSTTY_KEY_F4,
|
||||||
|
0x60: GHOSTTY_KEY_F5,
|
||||||
|
0x61: GHOSTTY_KEY_F6,
|
||||||
|
0x62: GHOSTTY_KEY_F7,
|
||||||
|
0x64: GHOSTTY_KEY_F8,
|
||||||
|
0x65: GHOSTTY_KEY_F9,
|
||||||
|
0x6D: GHOSTTY_KEY_F10,
|
||||||
|
0x67: GHOSTTY_KEY_F11,
|
||||||
|
0x6F: GHOSTTY_KEY_F12,
|
||||||
|
0x69: GHOSTTY_KEY_PRINT_SCREEN,
|
||||||
|
0x6B: GHOSTTY_KEY_F14,
|
||||||
|
0x71: GHOSTTY_KEY_F15,
|
||||||
|
0x6A: GHOSTTY_KEY_F16,
|
||||||
|
0x40: GHOSTTY_KEY_F17,
|
||||||
|
0x4F: GHOSTTY_KEY_F18,
|
||||||
|
0x50: GHOSTTY_KEY_F19,
|
||||||
|
0x5A: GHOSTTY_KEY_F20,
|
||||||
|
0x73: GHOSTTY_KEY_HOME,
|
||||||
|
0x72: GHOSTTY_KEY_INSERT,
|
||||||
|
0x7B: GHOSTTY_KEY_LEFT,
|
||||||
|
0x3A: GHOSTTY_KEY_LEFT_ALT,
|
||||||
|
0x3B: GHOSTTY_KEY_LEFT_CONTROL,
|
||||||
|
0x38: GHOSTTY_KEY_LEFT_SHIFT,
|
||||||
|
0x37: GHOSTTY_KEY_LEFT_SUPER,
|
||||||
|
0x47: GHOSTTY_KEY_NUM_LOCK,
|
||||||
|
0x79: GHOSTTY_KEY_PAGE_DOWN,
|
||||||
|
0x74: GHOSTTY_KEY_PAGE_UP,
|
||||||
|
0x7C: GHOSTTY_KEY_RIGHT,
|
||||||
|
0x3D: GHOSTTY_KEY_RIGHT_ALT,
|
||||||
|
0x3E: GHOSTTY_KEY_RIGHT_CONTROL,
|
||||||
|
0x3C: GHOSTTY_KEY_RIGHT_SHIFT,
|
||||||
|
0x36: GHOSTTY_KEY_RIGHT_SUPER,
|
||||||
|
0x31: GHOSTTY_KEY_SPACE,
|
||||||
|
0x30: GHOSTTY_KEY_TAB,
|
||||||
|
0x7E: GHOSTTY_KEY_UP,
|
||||||
|
|
||||||
|
0x52: GHOSTTY_KEY_KP_0,
|
||||||
|
0x53: GHOSTTY_KEY_KP_1,
|
||||||
|
0x54: GHOSTTY_KEY_KP_2,
|
||||||
|
0x55: GHOSTTY_KEY_KP_3,
|
||||||
|
0x56: GHOSTTY_KEY_KP_4,
|
||||||
|
0x57: GHOSTTY_KEY_KP_5,
|
||||||
|
0x58: GHOSTTY_KEY_KP_6,
|
||||||
|
0x59: GHOSTTY_KEY_KP_7,
|
||||||
|
0x5B: GHOSTTY_KEY_KP_8,
|
||||||
|
0x5C: GHOSTTY_KEY_KP_9,
|
||||||
|
0x45: GHOSTTY_KEY_KP_ADD,
|
||||||
|
0x41: GHOSTTY_KEY_KP_DECIMAL,
|
||||||
|
0x4B: GHOSTTY_KEY_KP_DIVIDE,
|
||||||
|
0x4C: GHOSTTY_KEY_KP_ENTER,
|
||||||
|
0x51: GHOSTTY_KEY_KP_EQUAL,
|
||||||
|
0x43: GHOSTTY_KEY_KP_MULTIPLY,
|
||||||
|
0x4E: GHOSTTY_KEY_KP_SUBTRACT,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Surface Notifications
|
||||||
|
|
||||||
|
extension Ghostty.Notification {
|
||||||
|
/// Posted when a new split is requested. The sending object will be the surface that had focus. The
|
||||||
|
/// userdata has one key "direction" with the direction to split to.
|
||||||
|
static let ghosttyNewSplit = Notification.Name("com.mitchellh.ghostty.newSplit")
|
||||||
|
|
||||||
|
/// Close the calling surface.
|
||||||
|
static let ghosttyCloseSurface = Notification.Name("com.mitchellh.ghostty.closeSurface")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Surface Environment Keys
|
||||||
|
|
||||||
|
private struct GhosttySurfaceViewKey: EnvironmentKey {
|
||||||
|
static let defaultValue: Ghostty.SurfaceView? = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EnvironmentValues {
|
||||||
|
var ghosttySurfaceView: Ghostty.SurfaceView? {
|
||||||
|
get { self[GhosttySurfaceViewKey.self] }
|
||||||
|
set { self[GhosttySurfaceViewKey.self] = newValue }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func ghosttySurfaceView(_ surfaceView: Ghostty.SurfaceView?) -> some View {
|
||||||
|
environment(\.ghosttySurfaceView, surfaceView)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: Surface Focus Keys
|
||||||
|
|
||||||
|
extension FocusedValues {
|
||||||
|
var ghosttySurfaceView: Ghostty.SurfaceView? {
|
||||||
|
get { self[FocusedGhosttySurface.self] }
|
||||||
|
set { self[FocusedGhosttySurface.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FocusedGhosttySurface: FocusedValueKey {
|
||||||
|
typealias Value = Ghostty.SurfaceView
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension FocusedValues {
|
||||||
|
var ghosttySurfaceTitle: String? {
|
||||||
|
get { self[FocusedGhosttySurfaceTitle.self] }
|
||||||
|
set { self[FocusedGhosttySurfaceTitle.self] = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct FocusedGhosttySurfaceTitle: FocusedValueKey {
|
||||||
|
typealias Value = String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,8 +10,11 @@ struct GhosttyApp: App {
|
|||||||
)
|
)
|
||||||
|
|
||||||
/// The ghostty global state. Only one per process.
|
/// The ghostty global state. Only one per process.
|
||||||
@StateObject private var ghostty = GhosttyState()
|
@StateObject private var ghostty = Ghostty.AppState()
|
||||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate;
|
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||||
|
|
||||||
|
/// The current focused Ghostty surface in this app
|
||||||
|
@FocusedValue(\.ghosttySurfaceView) private var focusedSurface
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
@ -21,13 +24,19 @@ struct GhosttyApp: App {
|
|||||||
case .error:
|
case .error:
|
||||||
ErrorView()
|
ErrorView()
|
||||||
case .ready:
|
case .ready:
|
||||||
TerminalView(app: ghostty.app!)
|
Ghostty.TerminalSplit(onClose: Self.closeWindow)
|
||||||
.modifier(WindowObservationModifier())
|
.ghosttyApp(ghostty.app!)
|
||||||
}
|
}
|
||||||
}.commands {
|
}.commands {
|
||||||
CommandGroup(after: .newItem) {
|
CommandGroup(after: .newItem) {
|
||||||
Button("New Tab", action: newTab).keyboardShortcut("t", modifiers: [.command])
|
Button("New Tab", action: Self.newTab).keyboardShortcut("t", modifiers: [.command])
|
||||||
}
|
Divider()
|
||||||
|
Button("Split Horizontally", action: splitHorizontally).keyboardShortcut("d", modifiers: [.command])
|
||||||
|
Button("Split Vertically", action: splitVertically).keyboardShortcut("d", modifiers: [.command, .shift])
|
||||||
|
Divider()
|
||||||
|
Button("Close", action: close).keyboardShortcut("w", modifiers: [.command])
|
||||||
|
Button("Close Window", action: Self.closeWindow).keyboardShortcut("w", modifiers: [.command, .shift])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Settings {
|
Settings {
|
||||||
@ -36,7 +45,7 @@ struct GhosttyApp: App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a new tab in the currently active window
|
// Create a new tab in the currently active window
|
||||||
func newTab() {
|
static func newTab() {
|
||||||
guard let currentWindow = NSApp.keyWindow else { return }
|
guard let currentWindow = NSApp.keyWindow else { return }
|
||||||
guard let windowController = currentWindow.windowController else { return }
|
guard let windowController = currentWindow.windowController else { return }
|
||||||
windowController.newWindowForTab(nil)
|
windowController.newWindowForTab(nil)
|
||||||
@ -44,135 +53,84 @@ struct GhosttyApp: App {
|
|||||||
currentWindow.addTabbedWindow(newWindow, ordered: .above)
|
currentWindow.addTabbedWindow(newWindow, ordered: .above)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func closeWindow() {
|
||||||
|
guard let currentWindow = NSApp.keyWindow else { return }
|
||||||
|
currentWindow.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func close() {
|
||||||
|
guard let surfaceView = focusedSurface else { return }
|
||||||
|
guard let surface = surfaceView.surface else { return }
|
||||||
|
ghostty.requestClose(surface: surface)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitHorizontally() {
|
||||||
|
guard let surfaceView = focusedSurface else { return }
|
||||||
|
guard let surface = surfaceView.surface else { return }
|
||||||
|
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT)
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitVertically() {
|
||||||
|
guard let surfaceView = focusedSurface else { return }
|
||||||
|
guard let surface = surfaceView.surface else { return }
|
||||||
|
ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
// See CursedMenuManager for more information.
|
||||||
|
private var menuManager: CursedMenuManager?
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
UserDefaults.standard.register(defaults: [
|
UserDefaults.standard.register(defaults: [
|
||||||
// Disable this so that repeated key events make it through to our terminal views.
|
// Disable this so that repeated key events make it through to our terminal views.
|
||||||
"ApplePressAndHoldEnabled": false,
|
"ApplePressAndHoldEnabled": false,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
// Create our menu manager to create some custom menu items that
|
||||||
|
// we can't create from SwiftUI.
|
||||||
|
menuManager = CursedMenuManager()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class GhosttyState: ObservableObject {
|
/// SwiftUI as of macOS 13.x provides no way to manage the default menu items that are created
|
||||||
enum Readiness {
|
/// as part of a WindowGroup. This class is prefixed with "Cursed" because this is a truly cursed
|
||||||
case loading, error, ready
|
/// solution to the problem and I think its quite brittle. As soon as SwiftUI supports a better option
|
||||||
}
|
/// we should conditionally compile for that when supported.
|
||||||
|
///
|
||||||
|
/// The way this works is by setting up KVO on various menu objects and reacting to it. For example,
|
||||||
|
/// when SwiftUI tries to add a "Close" menu, we intercept it and delete it. Nice try!
|
||||||
|
private class CursedMenuManager {
|
||||||
|
var mainToken: NSKeyValueObservation?
|
||||||
|
var fileToken: NSKeyValueObservation?
|
||||||
|
|
||||||
/// The readiness value of the state.
|
|
||||||
@Published var readiness: Readiness = .loading
|
|
||||||
|
|
||||||
/// The ghostty global configuration.
|
|
||||||
var config: ghostty_config_t? = nil
|
|
||||||
|
|
||||||
/// 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...
|
|
||||||
var app: ghostty_app_t? = nil
|
|
||||||
|
|
||||||
/// Cached clipboard string for `read_clipboard` callback.
|
|
||||||
private var cached_clipboard_string: String? = nil
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Initialize ghostty global state. This happens once per process.
|
// If the whole menu changed we want to setup our new KVO
|
||||||
guard ghostty_init() == GHOSTTY_SUCCESS else {
|
self.mainToken = NSApp.observe(\.mainMenu, options: .new) { app, change in
|
||||||
GhosttyApp.logger.critical("ghostty_init failed")
|
self.onNewMenu()
|
||||||
readiness = .error
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the global configuration.
|
// Initial setup
|
||||||
guard let cfg = ghostty_config_new() else {
|
onNewMenu()
|
||||||
GhosttyApp.logger.critical("ghostty_config_new failed")
|
|
||||||
readiness = .error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.config = cfg;
|
|
||||||
|
|
||||||
// Load our configuration files from the home directory.
|
|
||||||
ghostty_config_load_default_files(cfg);
|
|
||||||
ghostty_config_load_recursive_files(cfg);
|
|
||||||
|
|
||||||
// TODO: we'd probably do some config loading here... for now we'd
|
|
||||||
// have to do this synchronously. When we support config updating we can do
|
|
||||||
// this async and update later.
|
|
||||||
|
|
||||||
// Finalize will make our defaults available.
|
|
||||||
ghostty_config_finalize(cfg)
|
|
||||||
|
|
||||||
// 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(),
|
|
||||||
wakeup_cb: { userdata in GhosttyState.wakeup(userdata) },
|
|
||||||
set_title_cb: { userdata, title in GhosttyState.setTitle(userdata, title: title) },
|
|
||||||
read_clipboard_cb: { userdata in GhosttyState.readClipboard(userdata) },
|
|
||||||
write_clipboard_cb: { userdata, str in GhosttyState.writeClipboard(userdata, string: str) })
|
|
||||||
|
|
||||||
// Create the ghostty app.
|
|
||||||
guard let app = ghostty_app_new(&runtime_cfg, cfg) else {
|
|
||||||
GhosttyApp.logger.critical("ghostty_app_new failed")
|
|
||||||
readiness = .error
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.app = app
|
|
||||||
|
|
||||||
self.readiness = .ready
|
|
||||||
}
|
}
|
||||||
|
|
||||||
deinit {
|
private func onNewMenu() {
|
||||||
ghostty_app_free(app)
|
guard let menu = NSApp.mainMenu else { return }
|
||||||
ghostty_config_free(config)
|
guard let file = menu.item(withTitle: "File") else { return }
|
||||||
}
|
guard let submenu = file.submenu else { return }
|
||||||
|
fileToken = submenu.observe(\.items) { (_, _) in
|
||||||
func appTick() {
|
let remove = ["Close", "Close All"]
|
||||||
guard let app = self.app else { return }
|
|
||||||
ghostty_app_tick(app)
|
// We look for the items in reverse since we're removing only the
|
||||||
}
|
// ones SwiftUI inserts which are at the end. We make replacements
|
||||||
|
// which we DON'T want deleted.
|
||||||
// MARK: Ghostty Callbacks
|
let items = submenu.items.reversed()
|
||||||
|
remove.forEach { title in
|
||||||
static func readClipboard(_ userdata: UnsafeMutableRawPointer?) -> UnsafePointer<CChar>? {
|
if let item = items.first(where: { $0.title.caseInsensitiveCompare(title) == .orderedSame }) {
|
||||||
guard let appState = self.appState(fromSurface: userdata) else { return nil }
|
submenu.removeItem(item)
|
||||||
guard let str = NSPasteboard.general.string(forType: .string) else { return nil }
|
}
|
||||||
|
}
|
||||||
// Ghostty requires we cache the string because the pointer we return has to remain
|
}
|
||||||
// stable until the next call to readClipboard.
|
|
||||||
appState.cached_clipboard_string = str
|
|
||||||
return (str as NSString).utf8String
|
|
||||||
}
|
|
||||||
|
|
||||||
static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?) {
|
|
||||||
guard let valueStr = String(cString: string!, encoding: .utf8) else { return }
|
|
||||||
let pb = NSPasteboard.general
|
|
||||||
pb.declareTypes([.string], owner: nil)
|
|
||||||
pb.setString(valueStr, forType: .string)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func wakeup(_ userdata: UnsafeMutableRawPointer?) {
|
|
||||||
let state = Unmanaged<GhosttyState>.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 setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?) {
|
|
||||||
let surfaceView = Unmanaged<TerminalSurfaceView_Real>.fromOpaque(userdata!).takeUnretainedValue()
|
|
||||||
guard let titleStr = String(cString: title!, encoding: .utf8) else { return }
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
surfaceView.title = titleStr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the GhosttyState from the given userdata value.
|
|
||||||
static func appState(fromSurface userdata: UnsafeMutableRawPointer?) -> GhosttyState? {
|
|
||||||
let surfaceView = Unmanaged<TerminalSurfaceView_Real>.fromOpaque(userdata!).takeUnretainedValue()
|
|
||||||
guard let surface = surfaceView.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<GhosttyState>.fromOpaque(app_ud).takeUnretainedValue()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
68
macos/Sources/SplitView/SplitView.Divider.swift
Normal file
68
macos/Sources/SplitView/SplitView.Divider.swift
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
extension SplitView {
|
||||||
|
/// The split divider that is rendered and can be used to resize a split view.
|
||||||
|
struct Divider: View {
|
||||||
|
let direction: SplitViewDirection
|
||||||
|
let visibleSize: CGFloat
|
||||||
|
let invisibleSize: CGFloat
|
||||||
|
|
||||||
|
private var visibleWidth: CGFloat? {
|
||||||
|
switch (direction) {
|
||||||
|
case .horizontal:
|
||||||
|
return visibleSize
|
||||||
|
case .vertical:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var visibleHeight: CGFloat? {
|
||||||
|
switch (direction) {
|
||||||
|
case .horizontal:
|
||||||
|
return nil
|
||||||
|
case .vertical:
|
||||||
|
return visibleSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var invisibleWidth: CGFloat? {
|
||||||
|
switch (direction) {
|
||||||
|
case .horizontal:
|
||||||
|
return visibleSize + invisibleSize
|
||||||
|
case .vertical:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var invisibleHeight: CGFloat? {
|
||||||
|
switch (direction) {
|
||||||
|
case .horizontal:
|
||||||
|
return nil
|
||||||
|
case .vertical:
|
||||||
|
return visibleSize + invisibleSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
Color.clear
|
||||||
|
.frame(width: invisibleWidth, height: invisibleHeight)
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color.gray)
|
||||||
|
.frame(width: visibleWidth, height: visibleHeight)
|
||||||
|
}
|
||||||
|
.onHover { isHovered in
|
||||||
|
if (isHovered) {
|
||||||
|
switch (direction) {
|
||||||
|
case .horizontal:
|
||||||
|
NSCursor.resizeLeftRight.push()
|
||||||
|
case .vertical:
|
||||||
|
NSCursor.resizeUpDown.push()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
NSCursor.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
119
macos/Sources/SplitView/SplitView.swift
Normal file
119
macos/Sources/SplitView/SplitView.swift
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A split view shows a left and right (or top and bottom) view with a divider in the middle to do resizing.
|
||||||
|
/// The terminlogy "left" and "right" is always used but for vertical splits "left" is "top" and "right" is "bottom".
|
||||||
|
///
|
||||||
|
/// This view is purpose built for our use case and I imagine we'll continue to make it more configurable
|
||||||
|
/// as time goes on. For example, the splitter divider size and styling is all hardcoded.
|
||||||
|
struct SplitView<L: View, R: View>: View {
|
||||||
|
/// Direction of the split
|
||||||
|
let direction: SplitViewDirection
|
||||||
|
|
||||||
|
/// The left and right views to render.
|
||||||
|
let left: L
|
||||||
|
let right: R
|
||||||
|
|
||||||
|
/// The current fractional width of the split view. 0.5 means L/R are equally sized, for example.
|
||||||
|
@State var split: CGFloat = 0.5
|
||||||
|
|
||||||
|
/// The visible size of the splitter, in points. The invisible size is a transparent hitbox that can still
|
||||||
|
/// be used for getting a resize handle. The total width/height of the splitter is the sum of both.
|
||||||
|
private let splitterVisibleSize: CGFloat = 1
|
||||||
|
private let splitterInvisibleSize: CGFloat = 6
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
GeometryReader { geo in
|
||||||
|
let leftRect = self.leftRect(for: geo.size)
|
||||||
|
let rightRect = self.rightRect(for: geo.size, leftRect: leftRect)
|
||||||
|
let splitterPoint = self.splitterPoint(for: geo.size, leftRect: leftRect)
|
||||||
|
|
||||||
|
ZStack(alignment: .topLeading) {
|
||||||
|
left
|
||||||
|
.frame(width: leftRect.size.width, height: leftRect.size.height)
|
||||||
|
.offset(x: leftRect.origin.x, y: leftRect.origin.y)
|
||||||
|
right
|
||||||
|
.frame(width: rightRect.size.width, height: rightRect.size.height)
|
||||||
|
.offset(x: rightRect.origin.x, y: rightRect.origin.y)
|
||||||
|
Divider(direction: direction, visibleSize: splitterVisibleSize, invisibleSize: splitterInvisibleSize)
|
||||||
|
.position(splitterPoint)
|
||||||
|
.gesture(dragGesture(geo.size, splitterPoint: splitterPoint))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init(_ direction: SplitViewDirection, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) {
|
||||||
|
self.direction = direction
|
||||||
|
self.left = left()
|
||||||
|
self.right = right()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture {
|
||||||
|
return DragGesture()
|
||||||
|
.onChanged { gesture in
|
||||||
|
let minSize: CGFloat = 10
|
||||||
|
|
||||||
|
switch (direction) {
|
||||||
|
case .horizontal:
|
||||||
|
let new = min(max(minSize, gesture.location.x), size.width - minSize)
|
||||||
|
split = new / size.width
|
||||||
|
|
||||||
|
case .vertical:
|
||||||
|
let new = min(max(minSize, gesture.location.y), size.height - minSize)
|
||||||
|
split = new / size.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the bounding rect for the left view.
|
||||||
|
private func leftRect(for size: CGSize) -> CGRect {
|
||||||
|
// Initially the rect is the full size
|
||||||
|
var result = CGRect(x: 0, y: 0, width: size.width, height: size.height)
|
||||||
|
switch (direction) {
|
||||||
|
case .horizontal:
|
||||||
|
result.size.width = result.size.width * split
|
||||||
|
result.size.width -= splitterVisibleSize / 2
|
||||||
|
|
||||||
|
case .vertical:
|
||||||
|
result.size.height = result.size.height * split
|
||||||
|
result.size.height -= splitterVisibleSize / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the bounding rect for the right view.
|
||||||
|
private func rightRect(for size: CGSize, leftRect: CGRect) -> CGRect {
|
||||||
|
// Initially the rect is the full size
|
||||||
|
var result = CGRect(x: 0, y: 0, width: size.width, height: size.height)
|
||||||
|
switch (direction) {
|
||||||
|
case .horizontal:
|
||||||
|
// For horizontal layouts we offset the starting X by the left rect
|
||||||
|
// and make the width fit the remaining space.
|
||||||
|
result.origin.x += leftRect.size.width
|
||||||
|
result.origin.x += splitterVisibleSize / 2
|
||||||
|
result.size.width -= result.origin.x
|
||||||
|
|
||||||
|
case .vertical:
|
||||||
|
result.origin.y += leftRect.size.height
|
||||||
|
result.origin.y += splitterVisibleSize / 2
|
||||||
|
result.size.height -= result.origin.y
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calculates the point at which the splitter should be rendered.
|
||||||
|
private func splitterPoint(for size: CGSize, leftRect: CGRect) -> CGPoint {
|
||||||
|
switch (direction) {
|
||||||
|
case .horizontal:
|
||||||
|
return CGPoint(x: leftRect.size.width, y: size.height / 2)
|
||||||
|
|
||||||
|
case .vertical:
|
||||||
|
return CGPoint(x: size.width / 2, y: leftRect.size.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SplitViewDirection {
|
||||||
|
case horizontal, vertical
|
||||||
|
}
|
@ -1,486 +0,0 @@
|
|||||||
import OSLog
|
|
||||||
import SwiftUI
|
|
||||||
import GhosttyKit
|
|
||||||
|
|
||||||
/// A surface is terminology in Ghostty for a terminal surface, or a place where a terminal is actually drawn
|
|
||||||
/// and interacted with. The word "surface" is used because a surface may represent a window, a tab,
|
|
||||||
/// a split, a small preview pane, etc. It is ANYTHING that has a terminal drawn to it.
|
|
||||||
///
|
|
||||||
/// We just wrap an AppKit NSView here at the moment so that we can behave as low level as possible
|
|
||||||
/// since that is what the Metal renderer in Ghostty expects. In the future, it may make more sense to
|
|
||||||
/// wrap an MTKView and use that, but for legacy reasons we didn't do that to begin with.
|
|
||||||
struct TerminalSurfaceView: NSViewRepresentable {
|
|
||||||
static let logger = Logger(
|
|
||||||
subsystem: Bundle.main.bundleIdentifier!,
|
|
||||||
category: String(describing: TerminalSurfaceView.self)
|
|
||||||
)
|
|
||||||
|
|
||||||
/// This should be set to true wen the surface has focus. This is up to the parent because
|
|
||||||
/// focus is also defined by window focus. It is important this is set correctly since if it is
|
|
||||||
/// false then the surface will idle at almost 0% CPU.
|
|
||||||
let hasFocus: Bool
|
|
||||||
|
|
||||||
/// The size of the frame containing this view. We use this to update the the underlying
|
|
||||||
/// surface. This does not actually SET the size of our frame, this only sets the size
|
|
||||||
/// of our Metal surface for drawing.
|
|
||||||
///
|
|
||||||
/// Note: we do NOT use the NSView.resize function because SwiftUI on macOS 12
|
|
||||||
/// does not call this callback (macOS 13+ does).
|
|
||||||
///
|
|
||||||
/// The best approach is to wrap this view in a GeometryReader and pass in the geo.size.
|
|
||||||
let size: CGSize
|
|
||||||
|
|
||||||
/// This is set to the title of the surface as defined by the pty. Callers should use this to
|
|
||||||
/// set the appropriate title of the window/tab/split/etc. if they care.
|
|
||||||
@Binding var title: String
|
|
||||||
|
|
||||||
@StateObject private var state: TerminalSurfaceView_Real
|
|
||||||
|
|
||||||
init(_ app: ghostty_app_t, hasFocus: Bool, size: CGSize, title: Binding<String>) {
|
|
||||||
self._state = StateObject(wrappedValue: TerminalSurfaceView_Real(app))
|
|
||||||
self._title = title
|
|
||||||
self.hasFocus = hasFocus
|
|
||||||
self.size = size
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeNSView(context: Context) -> TerminalSurfaceView_Real {
|
|
||||||
// We need the view as part of the state to be created previously because
|
|
||||||
// the view is sent to the Ghostty API so that it can manipulate it
|
|
||||||
// directly since we draw on a render thread.
|
|
||||||
state.delegate = context.coordinator
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNSView(_ view: TerminalSurfaceView_Real, context: Context) {
|
|
||||||
state.delegate = context.coordinator
|
|
||||||
state.focusDidChange(hasFocus)
|
|
||||||
state.sizeDidChange(size)
|
|
||||||
}
|
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
|
||||||
return Coordinator(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Coordinator : TerminalSurfaceDelegate {
|
|
||||||
let view: TerminalSurfaceView
|
|
||||||
|
|
||||||
init(_ view: TerminalSurfaceView) {
|
|
||||||
self.view = view
|
|
||||||
}
|
|
||||||
|
|
||||||
func titleDidChange(to: String) {
|
|
||||||
view.title = to
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// We use the delegate pattern to receive notifications about important state changes in the surface.
|
|
||||||
protocol TerminalSurfaceDelegate: AnyObject {
|
|
||||||
func titleDidChange(to: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The actual NSView implementation for the terminal surface.
|
|
||||||
class TerminalSurfaceView_Real: NSView, NSTextInputClient, ObservableObject {
|
|
||||||
weak var delegate: TerminalSurfaceDelegate?
|
|
||||||
|
|
||||||
// The current title of the surface as defined by the pty. This can be
|
|
||||||
// changed with escape codes.
|
|
||||||
var title: String = "" {
|
|
||||||
didSet {
|
|
||||||
if let delegate = self.delegate {
|
|
||||||
delegate.titleDidChange(to: title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var surface: ghostty_surface_t? = nil
|
|
||||||
var error: Error? = nil
|
|
||||||
private var markedText: NSMutableAttributedString;
|
|
||||||
|
|
||||||
// We need to support being a first responder so that we can get input events
|
|
||||||
override var acceptsFirstResponder: Bool { return true }
|
|
||||||
|
|
||||||
// I don't thikn we need this but this lets us know we should redraw our layer
|
|
||||||
// so we'll use that to tell ghostty to refresh.
|
|
||||||
override var wantsUpdateLayer: Bool { return true }
|
|
||||||
|
|
||||||
// Mapping of event keyCode to ghostty input key values. This is cribbed from
|
|
||||||
// glfw mostly since we started as a glfw-based app way back in the day!
|
|
||||||
static let keycodes: [UInt16 : ghostty_input_key_e] = [
|
|
||||||
0x1D: GHOSTTY_KEY_ZERO,
|
|
||||||
0x12: GHOSTTY_KEY_ONE,
|
|
||||||
0x13: GHOSTTY_KEY_TWO,
|
|
||||||
0x14: GHOSTTY_KEY_THREE,
|
|
||||||
0x15: GHOSTTY_KEY_FOUR,
|
|
||||||
0x17: GHOSTTY_KEY_FIVE,
|
|
||||||
0x16: GHOSTTY_KEY_SIX,
|
|
||||||
0x1A: GHOSTTY_KEY_SEVEN,
|
|
||||||
0x1C: GHOSTTY_KEY_EIGHT,
|
|
||||||
0x19: GHOSTTY_KEY_NINE,
|
|
||||||
0x00: GHOSTTY_KEY_A,
|
|
||||||
0x0B: GHOSTTY_KEY_B,
|
|
||||||
0x08: GHOSTTY_KEY_C,
|
|
||||||
0x02: GHOSTTY_KEY_D,
|
|
||||||
0x0E: GHOSTTY_KEY_E,
|
|
||||||
0x03: GHOSTTY_KEY_F,
|
|
||||||
0x05: GHOSTTY_KEY_G,
|
|
||||||
0x04: GHOSTTY_KEY_H,
|
|
||||||
0x22: GHOSTTY_KEY_I,
|
|
||||||
0x26: GHOSTTY_KEY_J,
|
|
||||||
0x28: GHOSTTY_KEY_K,
|
|
||||||
0x25: GHOSTTY_KEY_L,
|
|
||||||
0x2E: GHOSTTY_KEY_M,
|
|
||||||
0x2D: GHOSTTY_KEY_N,
|
|
||||||
0x1F: GHOSTTY_KEY_O,
|
|
||||||
0x23: GHOSTTY_KEY_P,
|
|
||||||
0x0C: GHOSTTY_KEY_Q,
|
|
||||||
0x0F: GHOSTTY_KEY_R,
|
|
||||||
0x01: GHOSTTY_KEY_S,
|
|
||||||
0x11: GHOSTTY_KEY_T,
|
|
||||||
0x20: GHOSTTY_KEY_U,
|
|
||||||
0x09: GHOSTTY_KEY_V,
|
|
||||||
0x0D: GHOSTTY_KEY_W,
|
|
||||||
0x07: GHOSTTY_KEY_X,
|
|
||||||
0x10: GHOSTTY_KEY_Y,
|
|
||||||
0x06: GHOSTTY_KEY_Z,
|
|
||||||
|
|
||||||
0x27: GHOSTTY_KEY_APOSTROPHE,
|
|
||||||
0x2A: GHOSTTY_KEY_BACKSLASH,
|
|
||||||
0x2B: GHOSTTY_KEY_COMMA,
|
|
||||||
0x18: GHOSTTY_KEY_EQUAL,
|
|
||||||
0x32: GHOSTTY_KEY_GRAVE_ACCENT,
|
|
||||||
0x21: GHOSTTY_KEY_LEFT_BRACKET,
|
|
||||||
0x1B: GHOSTTY_KEY_MINUS,
|
|
||||||
0x2F: GHOSTTY_KEY_PERIOD,
|
|
||||||
0x1E: GHOSTTY_KEY_RIGHT_BRACKET,
|
|
||||||
0x29: GHOSTTY_KEY_SEMICOLON,
|
|
||||||
0x2C: GHOSTTY_KEY_SLASH,
|
|
||||||
|
|
||||||
0x33: GHOSTTY_KEY_BACKSPACE,
|
|
||||||
0x39: GHOSTTY_KEY_CAPS_LOCK,
|
|
||||||
0x75: GHOSTTY_KEY_DELETE,
|
|
||||||
0x7D: GHOSTTY_KEY_DOWN,
|
|
||||||
0x77: GHOSTTY_KEY_END,
|
|
||||||
0x24: GHOSTTY_KEY_ENTER,
|
|
||||||
0x35: GHOSTTY_KEY_ESCAPE,
|
|
||||||
0x7A: GHOSTTY_KEY_F1,
|
|
||||||
0x78: GHOSTTY_KEY_F2,
|
|
||||||
0x63: GHOSTTY_KEY_F3,
|
|
||||||
0x76: GHOSTTY_KEY_F4,
|
|
||||||
0x60: GHOSTTY_KEY_F5,
|
|
||||||
0x61: GHOSTTY_KEY_F6,
|
|
||||||
0x62: GHOSTTY_KEY_F7,
|
|
||||||
0x64: GHOSTTY_KEY_F8,
|
|
||||||
0x65: GHOSTTY_KEY_F9,
|
|
||||||
0x6D: GHOSTTY_KEY_F10,
|
|
||||||
0x67: GHOSTTY_KEY_F11,
|
|
||||||
0x6F: GHOSTTY_KEY_F12,
|
|
||||||
0x69: GHOSTTY_KEY_PRINT_SCREEN,
|
|
||||||
0x6B: GHOSTTY_KEY_F14,
|
|
||||||
0x71: GHOSTTY_KEY_F15,
|
|
||||||
0x6A: GHOSTTY_KEY_F16,
|
|
||||||
0x40: GHOSTTY_KEY_F17,
|
|
||||||
0x4F: GHOSTTY_KEY_F18,
|
|
||||||
0x50: GHOSTTY_KEY_F19,
|
|
||||||
0x5A: GHOSTTY_KEY_F20,
|
|
||||||
0x73: GHOSTTY_KEY_HOME,
|
|
||||||
0x72: GHOSTTY_KEY_INSERT,
|
|
||||||
0x7B: GHOSTTY_KEY_LEFT,
|
|
||||||
0x3A: GHOSTTY_KEY_LEFT_ALT,
|
|
||||||
0x3B: GHOSTTY_KEY_LEFT_CONTROL,
|
|
||||||
0x38: GHOSTTY_KEY_LEFT_SHIFT,
|
|
||||||
0x37: GHOSTTY_KEY_LEFT_SUPER,
|
|
||||||
0x47: GHOSTTY_KEY_NUM_LOCK,
|
|
||||||
0x79: GHOSTTY_KEY_PAGE_DOWN,
|
|
||||||
0x74: GHOSTTY_KEY_PAGE_UP,
|
|
||||||
0x7C: GHOSTTY_KEY_RIGHT,
|
|
||||||
0x3D: GHOSTTY_KEY_RIGHT_ALT,
|
|
||||||
0x3E: GHOSTTY_KEY_RIGHT_CONTROL,
|
|
||||||
0x3C: GHOSTTY_KEY_RIGHT_SHIFT,
|
|
||||||
0x36: GHOSTTY_KEY_RIGHT_SUPER,
|
|
||||||
0x31: GHOSTTY_KEY_SPACE,
|
|
||||||
0x30: GHOSTTY_KEY_TAB,
|
|
||||||
0x7E: GHOSTTY_KEY_UP,
|
|
||||||
|
|
||||||
0x52: GHOSTTY_KEY_KP_0,
|
|
||||||
0x53: GHOSTTY_KEY_KP_1,
|
|
||||||
0x54: GHOSTTY_KEY_KP_2,
|
|
||||||
0x55: GHOSTTY_KEY_KP_3,
|
|
||||||
0x56: GHOSTTY_KEY_KP_4,
|
|
||||||
0x57: GHOSTTY_KEY_KP_5,
|
|
||||||
0x58: GHOSTTY_KEY_KP_6,
|
|
||||||
0x59: GHOSTTY_KEY_KP_7,
|
|
||||||
0x5B: GHOSTTY_KEY_KP_8,
|
|
||||||
0x5C: GHOSTTY_KEY_KP_9,
|
|
||||||
0x45: GHOSTTY_KEY_KP_ADD,
|
|
||||||
0x41: GHOSTTY_KEY_KP_DECIMAL,
|
|
||||||
0x4B: GHOSTTY_KEY_KP_DIVIDE,
|
|
||||||
0x4C: GHOSTTY_KEY_KP_ENTER,
|
|
||||||
0x51: GHOSTTY_KEY_KP_EQUAL,
|
|
||||||
0x43: GHOSTTY_KEY_KP_MULTIPLY,
|
|
||||||
0x4E: GHOSTTY_KEY_KP_SUBTRACT,
|
|
||||||
];
|
|
||||||
|
|
||||||
init(_ app: ghostty_app_t) {
|
|
||||||
self.markedText = NSMutableAttributedString()
|
|
||||||
|
|
||||||
// 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: NSMakeRect(0, 0, 800, 600))
|
|
||||||
|
|
||||||
// Setup our surface. This will also initialize all the terminal IO.
|
|
||||||
var surface_cfg = ghostty_surface_config_s(
|
|
||||||
userdata: Unmanaged.passUnretained(self).toOpaque(),
|
|
||||||
nsview: Unmanaged.passUnretained(self).toOpaque(),
|
|
||||||
scale_factor: NSScreen.main!.backingScaleFactor)
|
|
||||||
guard let surface = ghostty_surface_new(app, &surface_cfg) else {
|
|
||||||
self.error = AppError.surfaceCreateError
|
|
||||||
return
|
|
||||||
}
|
|
||||||
self.surface = surface;
|
|
||||||
|
|
||||||
// Setup our tracking area so we get mouse moved events
|
|
||||||
updateTrackingAreas()
|
|
||||||
}
|
|
||||||
|
|
||||||
required init?(coder: NSCoder) {
|
|
||||||
fatalError("init(coder:) is not supported for this view")
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
trackingAreas.forEach { removeTrackingArea($0) }
|
|
||||||
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
ghostty_surface_free(surface)
|
|
||||||
}
|
|
||||||
|
|
||||||
func focusDidChange(_ focused: Bool) {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
ghostty_surface_set_focus(surface, focused)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sizeDidChange(_ size: CGSize) {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
// The size represents our final size we're going for.
|
|
||||||
let scaledSize = self.convertToBacking(size)
|
|
||||||
ghostty_surface_set_size(surface, UInt32(scaledSize.width), UInt32(scaledSize.height))
|
|
||||||
}
|
|
||||||
|
|
||||||
override func updateTrackingAreas() {
|
|
||||||
// To update our tracking area we just recreate it all.
|
|
||||||
trackingAreas.forEach { removeTrackingArea($0) }
|
|
||||||
|
|
||||||
// This tracking area is across the entire frame to notify us of mouse movements.
|
|
||||||
addTrackingArea(NSTrackingArea(
|
|
||||||
rect: frame,
|
|
||||||
options: [
|
|
||||||
.mouseEnteredAndExited,
|
|
||||||
.mouseMoved,
|
|
||||||
.inVisibleRect,
|
|
||||||
|
|
||||||
// It is possible this is incorrect when we have splits. This will make
|
|
||||||
// mouse events only happen while the terminal is focused. Is that what
|
|
||||||
// we want?
|
|
||||||
.activeWhenFirstResponder,
|
|
||||||
],
|
|
||||||
owner: self,
|
|
||||||
userInfo: nil))
|
|
||||||
}
|
|
||||||
|
|
||||||
override func resetCursorRects() {
|
|
||||||
discardCursorRects()
|
|
||||||
addCursorRect(frame, cursor: .iBeam)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func viewDidChangeBackingProperties() {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
|
|
||||||
// Detect our X/Y scale factor so we can update our surface
|
|
||||||
let fbFrame = self.convertToBacking(self.frame)
|
|
||||||
let xScale = fbFrame.size.width / self.frame.size.width
|
|
||||||
let yScale = fbFrame.size.height / self.frame.size.height
|
|
||||||
ghostty_surface_set_content_scale(surface, xScale, yScale)
|
|
||||||
|
|
||||||
// When our scale factor changes, so does our fb size so we send that too
|
|
||||||
ghostty_surface_set_size(surface, UInt32(fbFrame.size.width), UInt32(fbFrame.size.height))
|
|
||||||
}
|
|
||||||
|
|
||||||
override func updateLayer() {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
ghostty_surface_refresh(surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
override func mouseDown(with event: NSEvent) {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
let mods = Self.translateFlags(event.modifierFlags)
|
|
||||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_LEFT, mods)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func mouseUp(with event: NSEvent) {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
let mods = Self.translateFlags(event.modifierFlags)
|
|
||||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_LEFT, mods)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func rightMouseDown(with event: NSEvent) {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
let mods = Self.translateFlags(event.modifierFlags)
|
|
||||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_PRESS, GHOSTTY_MOUSE_RIGHT, mods)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func rightMouseUp(with event: NSEvent) {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
let mods = Self.translateFlags(event.modifierFlags)
|
|
||||||
ghostty_surface_mouse_button(surface, GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_RIGHT, mods)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func mouseMoved(with event: NSEvent) {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
|
|
||||||
// Convert window position to view position. Note (0, 0) is bottom left.
|
|
||||||
let pos = self.convert(event.locationInWindow, from: nil)
|
|
||||||
ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override func mouseDragged(with event: NSEvent) {
|
|
||||||
self.mouseMoved(with: event)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func scrollWheel(with event: NSEvent) {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
|
|
||||||
var x = event.scrollingDeltaX
|
|
||||||
var y = event.scrollingDeltaY
|
|
||||||
if event.hasPreciseScrollingDeltas {
|
|
||||||
x *= 0.1
|
|
||||||
y *= 0.1
|
|
||||||
}
|
|
||||||
|
|
||||||
ghostty_surface_mouse_scroll(surface, x, y)
|
|
||||||
}
|
|
||||||
|
|
||||||
override func keyDown(with event: NSEvent) {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
let key = Self.keycodes[event.keyCode] ?? GHOSTTY_KEY_INVALID
|
|
||||||
let mods = Self.translateFlags(event.modifierFlags)
|
|
||||||
let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS
|
|
||||||
ghostty_surface_key(surface, action, key, mods)
|
|
||||||
|
|
||||||
self.interpretKeyEvents([event])
|
|
||||||
}
|
|
||||||
|
|
||||||
override func keyUp(with event: NSEvent) {
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
let key = Self.keycodes[event.keyCode] ?? GHOSTTY_KEY_INVALID
|
|
||||||
let mods = Self.translateFlags(event.modifierFlags)
|
|
||||||
ghostty_surface_key(surface, GHOSTTY_ACTION_RELEASE, key, mods)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: NSTextInputClient
|
|
||||||
|
|
||||||
func hasMarkedText() -> Bool {
|
|
||||||
return markedText.length > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func markedRange() -> NSRange {
|
|
||||||
guard markedText.length > 0 else { return NSRange() }
|
|
||||||
return NSRange(0...(markedText.length-1))
|
|
||||||
}
|
|
||||||
|
|
||||||
func selectedRange() -> NSRange {
|
|
||||||
return NSRange()
|
|
||||||
}
|
|
||||||
|
|
||||||
func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {
|
|
||||||
switch string {
|
|
||||||
case let v as NSAttributedString:
|
|
||||||
self.markedText = NSMutableAttributedString(attributedString: v)
|
|
||||||
|
|
||||||
case let v as String:
|
|
||||||
self.markedText = NSMutableAttributedString(string: v)
|
|
||||||
|
|
||||||
default:
|
|
||||||
print("unknown marked text: \(string)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func unmarkText() {
|
|
||||||
self.markedText.mutableString.setString("")
|
|
||||||
}
|
|
||||||
|
|
||||||
func validAttributesForMarkedText() -> [NSAttributedString.Key] {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func characterIndex(for point: NSPoint) -> Int {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {
|
|
||||||
guard let surface = self.surface else {
|
|
||||||
return NSMakeRect(frame.origin.x, frame.origin.y, 0, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ghostty will tell us where it thinks an IME keyboard should render.
|
|
||||||
var x: Double = 0;
|
|
||||||
var y: Double = 0;
|
|
||||||
ghostty_surface_ime_point(surface, &x, &y)
|
|
||||||
|
|
||||||
// Ghostty coordinates are in top-left (0, 0) so we have to convert to
|
|
||||||
// bottom-left since that is what UIKit expects
|
|
||||||
let rect = NSMakeRect(x, frame.size.height - y, 0, 0)
|
|
||||||
|
|
||||||
// Convert from view to screen coordinates
|
|
||||||
guard let window = self.window else { return rect }
|
|
||||||
return window.convertToScreen(rect)
|
|
||||||
}
|
|
||||||
|
|
||||||
func insertText(_ string: Any, replacementRange: NSRange) {
|
|
||||||
// We must have an associated event
|
|
||||||
guard NSApp.currentEvent != nil else { return }
|
|
||||||
guard let surface = self.surface else { return }
|
|
||||||
|
|
||||||
// We want the string view of the any value
|
|
||||||
var chars = ""
|
|
||||||
switch (string) {
|
|
||||||
case let v as NSAttributedString:
|
|
||||||
chars = v.string
|
|
||||||
case let v as String:
|
|
||||||
chars = v
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for codepoint in chars.unicodeScalars {
|
|
||||||
ghostty_surface_char(surface, codepoint.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override func doCommand(by selector: Selector) {
|
|
||||||
// This currently just prevents NSBeep from interpretKeyEvents but in the future
|
|
||||||
// we may want to make some of this work.
|
|
||||||
|
|
||||||
print("SEL: \(selector)")
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func translateFlags(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {
|
|
||||||
var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue
|
|
||||||
if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue }
|
|
||||||
if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue }
|
|
||||||
if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue }
|
|
||||||
if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue }
|
|
||||||
if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue }
|
|
||||||
|
|
||||||
return ghostty_input_mods_e(mods)
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,24 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
import GhosttyKit
|
|
||||||
|
|
||||||
struct TerminalView: View {
|
|
||||||
let app: ghostty_app_t
|
|
||||||
@FocusState private var surfaceFocus: Bool
|
|
||||||
@Environment(\.isKeyWindow) private var isKeyWindow: Bool
|
|
||||||
@State private var title: String = "Ghostty"
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
private var hasFocus: Bool { surfaceFocus && isKeyWindow }
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
// We use a GeometryReader to get the frame bounds so that our metal surface
|
|
||||||
// is up to date. See TerminalSurfaceView for why we don't use the NSView
|
|
||||||
// resize callback.
|
|
||||||
GeometryReader { geo in
|
|
||||||
TerminalSurfaceView(app, hasFocus: hasFocus, size: geo.size, title: $title)
|
|
||||||
.focused($surfaceFocus)
|
|
||||||
.navigationTitle(title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,80 +0,0 @@
|
|||||||
import SwiftUI
|
|
||||||
|
|
||||||
/// This modifier tracks whether the window is the key window in the isKeyWindow environment value.
|
|
||||||
struct WindowObservationModifier: ViewModifier {
|
|
||||||
@StateObject var windowObserver: WindowObserver = WindowObserver()
|
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
|
||||||
content.background(
|
|
||||||
HostingWindowFinder { [weak windowObserver] window in
|
|
||||||
windowObserver?.window = window
|
|
||||||
}
|
|
||||||
).environment(\.isKeyWindow, windowObserver.isKeyWindow)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension EnvironmentValues {
|
|
||||||
struct IsKeyWindowKey: EnvironmentKey {
|
|
||||||
static var defaultValue: Bool = false
|
|
||||||
typealias Value = Bool
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate(set) var isKeyWindow: Bool {
|
|
||||||
get {
|
|
||||||
self[IsKeyWindowKey.self]
|
|
||||||
}
|
|
||||||
set {
|
|
||||||
self[IsKeyWindowKey.self] = newValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class WindowObserver: ObservableObject {
|
|
||||||
@Published public private(set) var isKeyWindow: Bool = false
|
|
||||||
|
|
||||||
private var becomeKeyobserver: NSObjectProtocol?
|
|
||||||
private var resignKeyobserver: NSObjectProtocol?
|
|
||||||
|
|
||||||
weak var window: NSWindow? {
|
|
||||||
didSet {
|
|
||||||
self.isKeyWindow = window?.isKeyWindow ?? false
|
|
||||||
guard let window = window else {
|
|
||||||
self.becomeKeyobserver = nil
|
|
||||||
self.resignKeyobserver = nil
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self.becomeKeyobserver = NotificationCenter.default.addObserver(
|
|
||||||
forName: NSWindow.didBecomeKeyNotification,
|
|
||||||
object: window,
|
|
||||||
queue: .main
|
|
||||||
) { (n) in
|
|
||||||
self.isKeyWindow = true
|
|
||||||
}
|
|
||||||
|
|
||||||
self.resignKeyobserver = NotificationCenter.default.addObserver(
|
|
||||||
forName: NSWindow.didResignKeyNotification,
|
|
||||||
object: window,
|
|
||||||
queue: .main
|
|
||||||
) { (n) in
|
|
||||||
self.isKeyWindow = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// This view calls the callback with the window value that hosts the view.
|
|
||||||
struct HostingWindowFinder: NSViewRepresentable {
|
|
||||||
var callback: (NSWindow?) -> ()
|
|
||||||
|
|
||||||
func makeNSView(context: Self.Context) -> NSView {
|
|
||||||
let view = NSView()
|
|
||||||
view.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
DispatchQueue.main.async { [weak view] in
|
|
||||||
self.callback(view?.window)
|
|
||||||
}
|
|
||||||
return view
|
|
||||||
}
|
|
||||||
|
|
||||||
func updateNSView(_ nsView: NSView, context: Context) {}
|
|
||||||
}
|
|
@ -476,6 +476,7 @@ pub fn deinit(self: *Surface) void {
|
|||||||
self.alloc.destroy(self.font_group);
|
self.alloc.destroy(self.font_group);
|
||||||
|
|
||||||
self.alloc.destroy(self.renderer_state.mutex);
|
self.alloc.destroy(self.renderer_state.mutex);
|
||||||
|
log.info("surface closed addr={x}", .{@ptrToInt(self)});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called from the app thread to handle mailbox messages to our specific
|
/// Called from the app thread to handle mailbox messages to our specific
|
||||||
@ -940,6 +941,18 @@ pub fn keyCallback(
|
|||||||
} else log.warn("runtime doesn't implement gotoTab", .{});
|
} else log.warn("runtime doesn't implement gotoTab", .{});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
.new_split => |direction| {
|
||||||
|
if (@hasDecl(apprt.Surface, "newSplit")) {
|
||||||
|
try self.rt_surface.newSplit(direction);
|
||||||
|
} else log.warn("runtime doesn't implement newSplit", .{});
|
||||||
|
},
|
||||||
|
|
||||||
|
.close_surface => {
|
||||||
|
if (@hasDecl(apprt.Surface, "closeSurface")) {
|
||||||
|
try self.rt_surface.closeSurface();
|
||||||
|
} else log.warn("runtime doesn't implement closeSurface", .{});
|
||||||
|
},
|
||||||
|
|
||||||
.close_window => {
|
.close_window => {
|
||||||
_ = self.app_mailbox.push(.{ .close = self }, .{ .instant = {} });
|
_ = self.app_mailbox.push(.{ .close = self }, .{ .instant = {} });
|
||||||
},
|
},
|
||||||
|
@ -44,6 +44,13 @@ pub const App = struct {
|
|||||||
|
|
||||||
/// Write the clipboard value.
|
/// Write the clipboard value.
|
||||||
write_clipboard: *const fn (SurfaceUD, [*:0]const u8) callconv(.C) void,
|
write_clipboard: *const fn (SurfaceUD, [*:0]const u8) callconv(.C) void,
|
||||||
|
|
||||||
|
/// Create a new split view. If the embedder doesn't support split
|
||||||
|
/// views then this can be null.
|
||||||
|
new_split: ?*const fn (SurfaceUD, input.SplitDirection) callconv(.C) void = null,
|
||||||
|
|
||||||
|
/// Close the current surface given by this function.
|
||||||
|
close_surface: ?*const fn (SurfaceUD) callconv(.C) void = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
core_app: *CoreApp,
|
core_app: *CoreApp,
|
||||||
@ -148,6 +155,24 @@ pub const Surface = struct {
|
|||||||
self.core_surface.deinit();
|
self.core_surface.deinit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn newSplit(self: *const Surface, direction: input.SplitDirection) !void {
|
||||||
|
const func = self.app.opts.new_split orelse {
|
||||||
|
log.info("runtime embedder does not support splits", .{});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
func(self.opts.userdata, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn closeSurface(self: *const Surface) !void {
|
||||||
|
const func = self.app.opts.close_surface orelse {
|
||||||
|
log.info("runtime embedder does not closing a surface", .{});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
func(self.opts.userdata);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
|
pub fn getContentScale(self: *const Surface) !apprt.ContentScale {
|
||||||
return self.content_scale;
|
return self.content_scale;
|
||||||
}
|
}
|
||||||
@ -445,4 +470,15 @@ pub const CAPI = struct {
|
|||||||
x.* = pos.x;
|
x.* = pos.x;
|
||||||
y.* = pos.y;
|
y.* = pos.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Request that the surface become closed. This will go through the
|
||||||
|
/// normal trigger process that a close surface input binding would.
|
||||||
|
export fn ghostty_surface_request_close(ptr: *Surface) void {
|
||||||
|
ptr.closeSurface() catch {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request that the surface split in the given direction.
|
||||||
|
export fn ghostty_surface_split(ptr: *Surface, direction: input.SplitDirection) void {
|
||||||
|
ptr.newSplit(direction) catch {};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
@ -279,6 +279,11 @@ pub const Config = struct {
|
|||||||
try result.keybind.set.put(
|
try result.keybind.set.put(
|
||||||
alloc,
|
alloc,
|
||||||
.{ .key = .w, .mods = .{ .super = true } },
|
.{ .key = .w, .mods = .{ .super = true } },
|
||||||
|
.{ .close_surface = {} },
|
||||||
|
);
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .w, .mods = .{ .super = true, .shift = true } },
|
||||||
.{ .close_window = {} },
|
.{ .close_window = {} },
|
||||||
);
|
);
|
||||||
try result.keybind.set.put(
|
try result.keybind.set.put(
|
||||||
@ -296,6 +301,16 @@ pub const Config = struct {
|
|||||||
.{ .key = .right_bracket, .mods = .{ .super = true, .shift = true } },
|
.{ .key = .right_bracket, .mods = .{ .super = true, .shift = true } },
|
||||||
.{ .next_tab = {} },
|
.{ .next_tab = {} },
|
||||||
);
|
);
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .d, .mods = .{ .super = true } },
|
||||||
|
.{ .new_split = .right },
|
||||||
|
);
|
||||||
|
try result.keybind.set.put(
|
||||||
|
alloc,
|
||||||
|
.{ .key = .d, .mods = .{ .super = true, .shift = true } },
|
||||||
|
.{ .new_split = .down },
|
||||||
|
);
|
||||||
{
|
{
|
||||||
// Cmd+N for goto tab N
|
// Cmd+N for goto tab N
|
||||||
const start = @enumToInt(inputpkg.Key.one);
|
const start = @enumToInt(inputpkg.Key.one);
|
||||||
|
@ -3,6 +3,7 @@ const std = @import("std");
|
|||||||
pub usingnamespace @import("input/mouse.zig");
|
pub usingnamespace @import("input/mouse.zig");
|
||||||
pub usingnamespace @import("input/key.zig");
|
pub usingnamespace @import("input/key.zig");
|
||||||
pub const Binding = @import("input/Binding.zig");
|
pub const Binding = @import("input/Binding.zig");
|
||||||
|
pub const SplitDirection = Binding.Action.SplitDirection;
|
||||||
|
|
||||||
test {
|
test {
|
||||||
std.testing.refAllDecls(@This());
|
std.testing.refAllDecls(@This());
|
||||||
|
@ -105,7 +105,20 @@ pub fn parse(input: []const u8) !Binding {
|
|||||||
// Cursor keys can't be set currently
|
// Cursor keys can't be set currently
|
||||||
Action.CursorKey => return Error.InvalidAction,
|
Action.CursorKey => return Error.InvalidAction,
|
||||||
|
|
||||||
else => unreachable,
|
else => switch (@typeInfo(field.type)) {
|
||||||
|
.Enum => {
|
||||||
|
const idx = colonIdx orelse return Error.InvalidFormat;
|
||||||
|
const param = actionRaw[idx + 1 ..];
|
||||||
|
const value = std.meta.stringToEnum(
|
||||||
|
field.type,
|
||||||
|
param,
|
||||||
|
) orelse return Error.InvalidFormat;
|
||||||
|
|
||||||
|
break :action @unionInit(Action, field.name, value);
|
||||||
|
},
|
||||||
|
|
||||||
|
else => unreachable,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -167,7 +180,15 @@ pub const Action = union(enum) {
|
|||||||
/// Go to the tab with the specific number, 1-indexed.
|
/// Go to the tab with the specific number, 1-indexed.
|
||||||
goto_tab: usize,
|
goto_tab: usize,
|
||||||
|
|
||||||
/// Close the current window or tab
|
/// Create a new split in the given direction. The new split will appear
|
||||||
|
/// in the direction given.
|
||||||
|
new_split: SplitDirection,
|
||||||
|
|
||||||
|
/// Close the current "surface", whether that is a window, tab, split,
|
||||||
|
/// etc. This only closes ONE surface.
|
||||||
|
close_surface: void,
|
||||||
|
|
||||||
|
/// Close the window, regardless of how many tabs or splits there may be.
|
||||||
close_window: void,
|
close_window: void,
|
||||||
|
|
||||||
/// Quit ghostty
|
/// Quit ghostty
|
||||||
@ -177,6 +198,15 @@ pub const Action = union(enum) {
|
|||||||
normal: []const u8,
|
normal: []const u8,
|
||||||
application: []const u8,
|
application: []const u8,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// This is made extern (c_int) to make interop easier with our embedded
|
||||||
|
// runtime. The small size cost doesn't make a difference in our union.
|
||||||
|
pub const SplitDirection = enum(c_int) {
|
||||||
|
right,
|
||||||
|
down,
|
||||||
|
|
||||||
|
// Note: we don't support top or left yet
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Trigger is the associated key state that can trigger an action.
|
/// Trigger is the associated key state that can trigger an action.
|
||||||
@ -286,11 +316,15 @@ test "parse: triggers" {
|
|||||||
try testing.expectError(Error.InvalidFormat, parse("a+b=ignore"));
|
try testing.expectError(Error.InvalidFormat, parse("a+b=ignore"));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "parse: action" {
|
test "parse: action invalid" {
|
||||||
const testing = std.testing;
|
const testing = std.testing;
|
||||||
|
|
||||||
// invalid action
|
// invalid action
|
||||||
try testing.expectError(Error.InvalidFormat, parse("a=nopenopenope"));
|
try testing.expectError(Error.InvalidFormat, parse("a=nopenopenope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parse: action no parameters" {
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
// no parameters
|
// no parameters
|
||||||
try testing.expectEqual(
|
try testing.expectEqual(
|
||||||
@ -298,6 +332,10 @@ test "parse: action" {
|
|||||||
try parse("a=ignore"),
|
try parse("a=ignore"),
|
||||||
);
|
);
|
||||||
try testing.expectError(Error.InvalidFormat, parse("a=ignore:A"));
|
try testing.expectError(Error.InvalidFormat, parse("a=ignore:A"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "parse: action with string" {
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
// parameter
|
// parameter
|
||||||
{
|
{
|
||||||
@ -306,3 +344,14 @@ test "parse: action" {
|
|||||||
try testing.expectEqualStrings("A", binding.action.csi);
|
try testing.expectEqualStrings("A", binding.action.csi);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "parse: action with enum" {
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
// parameter
|
||||||
|
{
|
||||||
|
const binding = try parse("a=new_split:right");
|
||||||
|
try testing.expect(binding.action == .new_split);
|
||||||
|
try testing.expectEqual(Action.SplitDirection.right, binding.action.new_split);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -18,6 +18,15 @@ const log = std.log.scoped(.io_thread);
|
|||||||
/// the future if we want it configurable.
|
/// the future if we want it configurable.
|
||||||
pub const Mailbox = BlockingQueue(termio.Message, 64);
|
pub const Mailbox = BlockingQueue(termio.Message, 64);
|
||||||
|
|
||||||
|
/// This stores the information that is coalesced.
|
||||||
|
const Coalesce = struct {
|
||||||
|
/// The number of milliseconds to coalesce certain messages like resize for.
|
||||||
|
/// Not all message types are coalesced.
|
||||||
|
const min_ms = 25;
|
||||||
|
|
||||||
|
resize: ?termio.Message.Resize = null,
|
||||||
|
};
|
||||||
|
|
||||||
/// Allocator used for some state
|
/// Allocator used for some state
|
||||||
alloc: std.mem.Allocator,
|
alloc: std.mem.Allocator,
|
||||||
|
|
||||||
@ -34,6 +43,12 @@ wakeup_c: xev.Completion = .{},
|
|||||||
stop: xev.Async,
|
stop: xev.Async,
|
||||||
stop_c: xev.Completion = .{},
|
stop_c: xev.Completion = .{},
|
||||||
|
|
||||||
|
/// This is used to coalesce resize events.
|
||||||
|
coalesce: xev.Timer,
|
||||||
|
coalesce_c: xev.Completion = .{},
|
||||||
|
coalesce_cancel_c: xev.Completion = .{},
|
||||||
|
coalesce_data: Coalesce = .{},
|
||||||
|
|
||||||
/// The underlying IO implementation.
|
/// The underlying IO implementation.
|
||||||
impl: *termio.Impl,
|
impl: *termio.Impl,
|
||||||
|
|
||||||
@ -60,6 +75,10 @@ pub fn init(
|
|||||||
var stop_h = try xev.Async.init();
|
var stop_h = try xev.Async.init();
|
||||||
errdefer stop_h.deinit();
|
errdefer stop_h.deinit();
|
||||||
|
|
||||||
|
// This timer is used to coalesce resize events.
|
||||||
|
var coalesce_h = try xev.Timer.init();
|
||||||
|
errdefer coalesce_h.deinit();
|
||||||
|
|
||||||
// The mailbox for messaging this thread
|
// The mailbox for messaging this thread
|
||||||
var mailbox = try Mailbox.create(alloc);
|
var mailbox = try Mailbox.create(alloc);
|
||||||
errdefer mailbox.destroy(alloc);
|
errdefer mailbox.destroy(alloc);
|
||||||
@ -69,6 +88,7 @@ pub fn init(
|
|||||||
.loop = loop,
|
.loop = loop,
|
||||||
.wakeup = wakeup_h,
|
.wakeup = wakeup_h,
|
||||||
.stop = stop_h,
|
.stop = stop_h,
|
||||||
|
.coalesce = coalesce_h,
|
||||||
.impl = impl,
|
.impl = impl,
|
||||||
.mailbox = mailbox,
|
.mailbox = mailbox,
|
||||||
};
|
};
|
||||||
@ -77,6 +97,7 @@ pub fn init(
|
|||||||
/// Clean up the thread. This is only safe to call once the thread
|
/// Clean up the thread. This is only safe to call once the thread
|
||||||
/// completes executing; the caller must join prior to this.
|
/// completes executing; the caller must join prior to this.
|
||||||
pub fn deinit(self: *Thread) void {
|
pub fn deinit(self: *Thread) void {
|
||||||
|
self.coalesce.deinit();
|
||||||
self.stop.deinit();
|
self.stop.deinit();
|
||||||
self.wakeup.deinit();
|
self.wakeup.deinit();
|
||||||
self.loop.deinit();
|
self.loop.deinit();
|
||||||
@ -129,7 +150,7 @@ fn drainMailbox(self: *Thread) !void {
|
|||||||
|
|
||||||
log.debug("mailbox message={}", .{message});
|
log.debug("mailbox message={}", .{message});
|
||||||
switch (message) {
|
switch (message) {
|
||||||
.resize => |v| try self.impl.resize(v.grid_size, v.screen_size, v.padding),
|
.resize => |v| self.handleResize(v),
|
||||||
.clear_screen => |v| try self.impl.clearScreen(v.history),
|
.clear_screen => |v| try self.impl.clearScreen(v.history),
|
||||||
.write_small => |v| try self.impl.queueWrite(v.data[0..v.len]),
|
.write_small => |v| try self.impl.queueWrite(v.data[0..v.len]),
|
||||||
.write_stable => |v| try self.impl.queueWrite(v),
|
.write_stable => |v| try self.impl.queueWrite(v),
|
||||||
@ -147,6 +168,51 @@ fn drainMailbox(self: *Thread) !void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handleResize(self: *Thread, resize: termio.Message.Resize) void {
|
||||||
|
self.coalesce_data.resize = resize;
|
||||||
|
|
||||||
|
// If the timer is already active we just return. In the future we want
|
||||||
|
// to reset the timer up to a maximum wait time but for now this ensures
|
||||||
|
// relatively smooth resizing.
|
||||||
|
if (self.coalesce_c.state() == .active) return;
|
||||||
|
|
||||||
|
self.coalesce.reset(
|
||||||
|
&self.loop,
|
||||||
|
&self.coalesce_c,
|
||||||
|
&self.coalesce_cancel_c,
|
||||||
|
Coalesce.min_ms,
|
||||||
|
Thread,
|
||||||
|
self,
|
||||||
|
coalesceCallback,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn coalesceCallback(
|
||||||
|
self_: ?*Thread,
|
||||||
|
_: *xev.Loop,
|
||||||
|
_: *xev.Completion,
|
||||||
|
r: xev.Timer.RunError!void,
|
||||||
|
) xev.CallbackAction {
|
||||||
|
_ = r catch |err| switch (err) {
|
||||||
|
error.Canceled => {},
|
||||||
|
else => {
|
||||||
|
log.warn("error during coalesce callback err={}", .{err});
|
||||||
|
return .disarm;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const self = self_ orelse return .disarm;
|
||||||
|
|
||||||
|
if (self.coalesce_data.resize) |v| {
|
||||||
|
self.coalesce_data.resize = null;
|
||||||
|
self.impl.resize(v.grid_size, v.screen_size, v.padding) catch |err| {
|
||||||
|
log.warn("error during resize err={}", .{err});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return .disarm;
|
||||||
|
}
|
||||||
|
|
||||||
fn wakeupCallback(
|
fn wakeupCallback(
|
||||||
self_: ?*Thread,
|
self_: ?*Thread,
|
||||||
_: *xev.Loop,
|
_: *xev.Loop,
|
||||||
|
@ -15,8 +15,7 @@ pub const Message = union(enum) {
|
|||||||
/// in the future.
|
/// in the future.
|
||||||
pub const WriteReq = MessageData(u8, 38);
|
pub const WriteReq = MessageData(u8, 38);
|
||||||
|
|
||||||
/// Resize the window.
|
pub const Resize = struct {
|
||||||
resize: struct {
|
|
||||||
/// The grid size for the given screen size with padding applied.
|
/// The grid size for the given screen size with padding applied.
|
||||||
grid_size: renderer.GridSize,
|
grid_size: renderer.GridSize,
|
||||||
|
|
||||||
@ -27,7 +26,10 @@ pub const Message = union(enum) {
|
|||||||
/// The padding, so that the terminal implementation can subtract
|
/// The padding, so that the terminal implementation can subtract
|
||||||
/// this to send to the pty.
|
/// this to send to the pty.
|
||||||
padding: renderer.Padding,
|
padding: renderer.Padding,
|
||||||
},
|
};
|
||||||
|
|
||||||
|
/// Resize the window.
|
||||||
|
resize: Resize,
|
||||||
|
|
||||||
/// Clear the screen.
|
/// Clear the screen.
|
||||||
clear_screen: struct {
|
clear_screen: struct {
|
||||||
|
Reference in New Issue
Block a user