macos: massive reorg

This commit is contained in:
Mitchell Hashimoto
2023-03-06 21:28:09 -08:00
parent 1a4fabc2e5
commit 1a3cd852f9
8 changed files with 822 additions and 735 deletions

View File

@ -7,11 +7,13 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
A507573E299FF33C009D7DC7 /* TerminalSurface.swift in Sources */ = {isa = PBXBuildFile; fileRef = A507573D299FF33C009D7DC7 /* TerminalSurface.swift */; };
A518502429A197C700E4CC4F /* TerminalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518502329A197C700E4CC4F /* TerminalView.swift */; };
A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518502529A1A45100E4CC4F /* WindowTracker.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 /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBD29B701360055DE60 /* 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 */; };
@ -19,11 +21,13 @@
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
A507573D299FF33C009D7DC7 /* TerminalSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurface.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>"; }; 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 /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = 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>"; };
@ -48,17 +52,27 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A5D495A0299BEC2200DD1313 /* Preview Content */, A5D495A0299BEC2200DD1313 /* Preview Content */,
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 /* TerminalSurface.swift */,
A518502529A1A45100E4CC4F /* WindowTracker.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 /* SplitView.swift */,
);
path = Ghostty;
sourceTree = "<group>";
};
A5B30528299BEAAA0047F10C = { A5B30528299BEAAA0047F10C = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@ -162,11 +176,13 @@
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 */, A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */,
A518502429A197C700E4CC4F /* TerminalView.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */,
A55B7BBE29B701360055DE60 /* SplitView.swift in Sources */,
A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */,
A55685E029A03A9F004303CE /* AppError.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */,
A507573E299FF33C009D7DC7 /* TerminalSurface.swift in Sources */,
A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */,
A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */, A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */,
); );

View File

@ -0,0 +1,147 @@
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) })
// 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)
}
// MARK: Ghostty Callbacks
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 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()
}
}
}
// 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)
}
}

View File

@ -0,0 +1 @@
struct Ghostty {}

View File

@ -0,0 +1,136 @@
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 TerminalSplitView: View {
@Environment(\.ghosttyApp) private var app
var body: some View {
if let app = app {
SplitViewChild(app)
}
}
}
private struct SplitViewChild: View {
enum Direction {
case none
case vertical
case horizontal
}
enum Side: Hashable {
case TopLeft
case BottomRight
}
/// The stored state between invocations.
class ViewState: ObservableObject {
/// The direction of the split currently
@Published var direction: Direction = .none
/// The top or left view. This is always set.
@Published var topLeft: Ghostty.SurfaceView
/// The bottom or right view. This can be nil if the direction == .none.
@Published var bottomRight: Ghostty.SurfaceView? = nil
/// Initialize the view state for the first time. This will create our topLeft view from new.
init(_ app: ghostty_app_t) {
self.topLeft = Ghostty.SurfaceView(app)
}
/// Initialize the view state using an existing top left. This is usually used when a split happens and
/// the child view inherits the top left.
init(topLeft: Ghostty.SurfaceView) {
self.topLeft = topLeft
}
}
let app: ghostty_app_t
@StateObject private var state: ViewState
@FocusState private var focusedSide: Side?
init(_ app: ghostty_app_t) {
self.app = app
_state = StateObject(wrappedValue: ViewState(app))
}
init(_ app: ghostty_app_t, topLeft: Ghostty.SurfaceView) {
self.app = app
_state = StateObject(wrappedValue: ViewState(topLeft: topLeft))
}
func split(to: Direction) {
assert(to != .none)
assert(state.direction == .none)
// Make the split the desired value
state.direction = to
// Create the new split which always goes to the bottom right.
state.bottomRight = Ghostty.SurfaceView(app)
}
func closeTopLeft() {
assert(state.direction != .none)
assert(state.bottomRight != nil)
state.topLeft = state.bottomRight!
state.direction = .none
}
func closeBottomRight() {
assert(state.direction != .none)
assert(state.bottomRight != nil)
state.bottomRight = nil
state.direction = .none
}
var body: some View {
switch (state.direction) {
case .none:
VStack {
HStack {
Button("Split Horizontal") { split(to: .horizontal) }
Button("Split Vertical") { split(to: .vertical) }
}
SurfaceWrapper(surfaceView: state.topLeft)
.focused($focusedSide, equals: .TopLeft)
}
case .horizontal:
VStack {
HStack {
Button("Close Left") { closeTopLeft() }
Button("Close Right") { closeBottomRight() }
}
HSplitView {
SplitViewChild(app, topLeft: state.topLeft)
.focused($focusedSide, equals: .TopLeft)
SplitViewChild(app, topLeft: state.bottomRight!)
.focused($focusedSide, equals: .BottomRight)
}
}
case .vertical:
VStack {
HStack {
Button("Close Top") { closeTopLeft() }
Button("Close Bottom") { closeBottomRight() }
}
VSplitView {
SplitViewChild(app, topLeft: state.topLeft)
.focused($focusedSide, equals: .TopLeft)
SplitViewChild(app, topLeft: state.bottomRight!)
.focused($focusedSide, equals: .BottomRight)
}
}
}
}
}
}

View File

@ -0,0 +1,511 @@
import SwiftUI
import GhosttyKit
extension Ghostty {
/// Render a terminal for the active app in the environment.
struct Terminal: View {
@Environment(\.ghosttyApp) private var app
var body: some View {
if let app = self.app {
TerminalForApp(app)
}
}
}
private struct TerminalForApp: View {
@StateObject private var surfaceView: SurfaceView
init(_ app: ghostty_app_t) {
_surfaceView = StateObject(wrappedValue: SurfaceView(app))
}
var body: some View {
SurfaceWrapper(surfaceView: 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
@Environment(\.isKeyWindow) private var isKeyWindow: Bool
// 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
Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size)
.focused($surfaceFocus)
.navigationTitle(surfaceView.title)
}
.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 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 }
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)
}
// 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 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)
}
}

View File

@ -10,7 +10,7 @@ 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;
var body: some Scene { var body: some Scene {
@ -21,7 +21,8 @@ struct GhosttyApp: App {
case .error: case .error:
ErrorView() ErrorView()
case .ready: case .ready:
TerminalSplittableView(ghostty.app!) Ghostty.TerminalSplitView()
.ghosttyApp(ghostty.app!)
.modifier(WindowObservationModifier()) .modifier(WindowObservationModifier())
} }
}.commands { }.commands {
@ -54,125 +55,3 @@ class AppDelegate: NSObject, NSApplicationDelegate {
]) ])
} }
} }
class GhosttyState: ObservableObject {
enum Readiness {
case loading, error, ready
}
/// 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() {
// 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 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 {
ghostty_app_free(app)
ghostty_config_free(config)
}
func appTick() {
guard let app = self.app else { return }
ghostty_app_tick(app)
}
// MARK: Ghostty Callbacks
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<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>.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>.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()
}
}

View File

@ -1,447 +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 TerminalSurface: NSViewRepresentable {
static let logger = Logger(
subsystem: Bundle.main.bundleIdentifier!,
category: String(describing: TerminalSurfaceView.self)
)
/// The view to render for the terminal surface.
let view: TerminalSurfaceView
/// 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) -> TerminalSurfaceView {
// 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: TerminalSurfaceView, context: Context) {
view.focusDidChange(hasFocus)
view.sizeDidChange(size)
}
}
/// The actual NSView implementation for the terminal surface.
class TerminalSurfaceView: 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 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 }
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)
}
// 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,
];
}

View File

@ -1,156 +0,0 @@
import SwiftUI
import GhosttyKit
struct TerminalView: 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: TerminalSurfaceView
@FocusState private var surfaceFocus: Bool
@Environment(\.isKeyWindow) private var isKeyWindow: Bool
// 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 }
// Initialize a TerminalView with a new surface view state.
init(_ app: ghostty_app_t) {
self.surfaceView = TerminalSurfaceView(app)
}
init(surface: TerminalSurfaceView) {
self.surfaceView = surface
}
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
TerminalSurface(view: surfaceView, hasFocus: hasFocus, size: geo.size)
.focused($surfaceFocus)
.navigationTitle(surfaceView.title)
}
}
}
/// 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 TerminalSplittableView: View {
enum Direction {
case none
case vertical
case horizontal
}
enum Side: Hashable {
case TopLeft
case BottomRight
}
/// The stored state between invocations.
class ViewState: ObservableObject {
/// The direction of the split currently
@Published var direction: Direction = .none
/// The top or left view. This is always set.
@Published var topLeft: TerminalSurfaceView
/// The bottom or right view. This can be nil if the direction == .none.
@Published var bottomRight: TerminalSurfaceView? = nil
/// Initialize the view state for the first time. This will create our topLeft view from new.
init(_ app: ghostty_app_t) {
self.topLeft = TerminalSurfaceView(app)
}
/// Initialize the view state using an existing top left. This is usually used when a split happens and
/// the child view inherits the top left.
init(topLeft: TerminalSurfaceView) {
self.topLeft = topLeft
}
}
let app: ghostty_app_t
@StateObject private var state: ViewState
@FocusState private var focusedSide: Side?
init(_ app: ghostty_app_t) {
self.app = app
_state = StateObject(wrappedValue: ViewState(app))
}
init(_ app: ghostty_app_t, topLeft: TerminalSurfaceView) {
self.app = app
_state = StateObject(wrappedValue: ViewState(topLeft: topLeft))
}
func split(to: Direction) {
assert(to != .none)
assert(state.direction == .none)
// Make the split the desired value
state.direction = to
// Create the new split which always goes to the bottom right.
state.bottomRight = TerminalSurfaceView(app)
}
func closeTopLeft() {
assert(state.direction != .none)
assert(state.bottomRight != nil)
state.topLeft = state.bottomRight!
state.direction = .none
}
func closeBottomRight() {
assert(state.direction != .none)
assert(state.bottomRight != nil)
state.bottomRight = nil
state.direction = .none
}
var body: some View {
switch (state.direction) {
case .none:
VStack {
HStack {
Button("Split Horizontal") { split(to: .horizontal) }
Button("Split Vertical") { split(to: .vertical) }
}
TerminalView(surface: state.topLeft)
.focused($focusedSide, equals: .TopLeft)
}
case .horizontal:
VStack {
HStack {
Button("Close Left") { closeTopLeft() }
Button("Close Right") { closeBottomRight() }
}
HSplitView {
TerminalSplittableView(app, topLeft: state.topLeft)
.focused($focusedSide, equals: .TopLeft)
TerminalSplittableView(app, topLeft: state.bottomRight!)
.focused($focusedSide, equals: .BottomRight)
}
}
case .vertical:
VStack {
HStack {
Button("Close Top") { closeTopLeft() }
Button("Close Bottom") { closeBottomRight() }
}
VSplitView {
TerminalSplittableView(app, topLeft: state.topLeft)
.focused($focusedSide, equals: .TopLeft)
TerminalSplittableView(app, topLeft: state.bottomRight!)
.focused($focusedSide, equals: .BottomRight)
}
}
}
}
}