From b1d57cd500e75a3ca0c5a2735b28ae129023333a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Mar 2023 13:18:19 -0800 Subject: [PATCH 01/27] macos: rename TerminalSurfaceView to TerminalSurface --- macos/Ghostty.xcodeproj/project.pbxproj | 8 +-- macos/Sources/GhosttyApp.swift | 6 +- ...urfaceView.swift => TerminalSurface.swift} | 16 ++--- macos/Sources/TerminalView.swift | 61 ++++++++++++++++++- 4 files changed, 75 insertions(+), 16 deletions(-) rename macos/Sources/{TerminalSurfaceView.swift => TerminalSurface.swift} (97%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 3ec37dec1..e0446a021 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - A507573E299FF33C009D7DC7 /* TerminalSurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */; }; + 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 */; }; A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; }; @@ -19,7 +19,7 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurfaceView.swift; sourceTree = ""; }; + A507573D299FF33C009D7DC7 /* TerminalSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurface.swift; sourceTree = ""; }; A518502329A197C700E4CC4F /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; A518502529A1A45100E4CC4F /* WindowTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTracker.swift; sourceTree = ""; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; @@ -53,7 +53,7 @@ A55685DF29A03A9F004303CE /* AppError.swift */, A59444F629A2ED5200725BBA /* SettingsView.swift */, A518502329A197C700E4CC4F /* TerminalView.swift */, - A507573D299FF33C009D7DC7 /* TerminalSurfaceView.swift */, + A507573D299FF33C009D7DC7 /* TerminalSurface.swift */, A518502529A1A45100E4CC4F /* WindowTracker.swift */, ); path = Sources; @@ -166,7 +166,7 @@ A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */, A518502429A197C700E4CC4F /* TerminalView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, - A507573E299FF33C009D7DC7 /* TerminalSurfaceView.swift in Sources */, + A507573E299FF33C009D7DC7 /* TerminalSurface.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */, ); diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index ecb6c9906..1651c98f8 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -21,7 +21,7 @@ struct GhosttyApp: App { case .error: ErrorView() case .ready: - TerminalView(app: ghostty.app!) + TerminalSplittableView(app: ghostty.app!) .modifier(WindowObservationModifier()) } }.commands { @@ -160,7 +160,7 @@ class GhosttyState: ObservableObject { } static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?) { - let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() guard let titleStr = String(cString: title!, encoding: .utf8) else { return } DispatchQueue.main.async { surfaceView.title = titleStr @@ -169,7 +169,7 @@ class GhosttyState: ObservableObject { /// Returns the GhosttyState from the given userdata value. static func appState(fromSurface userdata: UnsafeMutableRawPointer?) -> GhosttyState? { - let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + let surfaceView = Unmanaged.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 } diff --git a/macos/Sources/TerminalSurfaceView.swift b/macos/Sources/TerminalSurface.swift similarity index 97% rename from macos/Sources/TerminalSurfaceView.swift rename to macos/Sources/TerminalSurface.swift index c790646ed..ccde2e3ec 100644 --- a/macos/Sources/TerminalSurfaceView.swift +++ b/macos/Sources/TerminalSurface.swift @@ -9,7 +9,7 @@ import GhosttyKit /// 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 { +struct TerminalSurface: NSViewRepresentable { static let logger = Logger( subsystem: Bundle.main.bundleIdentifier!, category: String(describing: TerminalSurfaceView.self) @@ -34,16 +34,16 @@ struct TerminalSurfaceView: NSViewRepresentable { /// set the appropriate title of the window/tab/split/etc. if they care. @Binding var title: String - @StateObject private var state: TerminalSurfaceView_Real + @StateObject private var state: TerminalSurfaceView init(_ app: ghostty_app_t, hasFocus: Bool, size: CGSize, title: Binding) { - self._state = StateObject(wrappedValue: TerminalSurfaceView_Real(app)) + self._state = StateObject(wrappedValue: TerminalSurfaceView(app)) self._title = title self.hasFocus = hasFocus self.size = size } - func makeNSView(context: Context) -> TerminalSurfaceView_Real { + 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. @@ -51,7 +51,7 @@ struct TerminalSurfaceView: NSViewRepresentable { return state; } - func updateNSView(_ view: TerminalSurfaceView_Real, context: Context) { + func updateNSView(_ view: TerminalSurfaceView, context: Context) { state.delegate = context.coordinator state.focusDidChange(hasFocus) state.sizeDidChange(size) @@ -62,9 +62,9 @@ struct TerminalSurfaceView: NSViewRepresentable { } class Coordinator : TerminalSurfaceDelegate { - let view: TerminalSurfaceView + let view: TerminalSurface - init(_ view: TerminalSurfaceView) { + init(_ view: TerminalSurface) { self.view = view } @@ -80,7 +80,7 @@ protocol TerminalSurfaceDelegate: AnyObject { } /// The actual NSView implementation for the terminal surface. -class TerminalSurfaceView_Real: NSView, NSTextInputClient, ObservableObject { +class TerminalSurfaceView: NSView, NSTextInputClient, ObservableObject { weak var delegate: TerminalSurfaceDelegate? // The current title of the surface as defined by the pty. This can be diff --git a/macos/Sources/TerminalView.swift b/macos/Sources/TerminalView.swift index b03abbcb9..830b3de68 100644 --- a/macos/Sources/TerminalView.swift +++ b/macos/Sources/TerminalView.swift @@ -16,9 +16,68 @@ struct TerminalView: View { // 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) + TerminalSurface(app, hasFocus: hasFocus, size: geo.size, title: $title) .focused($surfaceFocus) .navigationTitle(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 + } + + @FocusState private var focusedSide: Side? + + let app: ghostty_app_t + + /// Direction of the current split. If this is "nil" then the terminal is not currently split at all. + @State var splitDirection: Direction = .none + + var body: some View { + switch (splitDirection) { + case .none: + VStack { + HStack { + Button("Split Horizontal") { splitDirection = .horizontal } + Button("Split Vertical") { splitDirection = .vertical } + } + + TerminalView(app: app) + .focused($focusedSide, equals: .TopLeft) + } + case .horizontal: + VStack { + HStack { + Button("Close Left") { splitDirection = .none } + Button("Close Right") { splitDirection = .none } + } + + HSplitView { + TerminalSplittableView(app: app) + .focused($focusedSide, equals: .TopLeft) + TerminalSplittableView(app: app) + .focused($focusedSide, equals: .BottomRight) + } + } + case .vertical: + VSplitView { + TerminalSplittableView(app: app) + .focused($focusedSide, equals: .TopLeft) + TerminalSplittableView(app: app) + .focused($focusedSide, equals: .BottomRight) + } + } + } +} From 1fbbdd3fc7a4824bcca5bb321f0683e83e12fc56 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Mar 2023 13:37:01 -0800 Subject: [PATCH 02/27] macos: making the surface state get passed down... --- macos/Sources/TerminalSurface.swift | 265 ++++++++++++++-------------- macos/Sources/TerminalView.swift | 13 +- 2 files changed, 141 insertions(+), 137 deletions(-) diff --git a/macos/Sources/TerminalSurface.swift b/macos/Sources/TerminalSurface.swift index ccde2e3ec..d1817988b 100644 --- a/macos/Sources/TerminalSurface.swift +++ b/macos/Sources/TerminalSurface.swift @@ -15,6 +15,9 @@ struct TerminalSurface: NSViewRepresentable { 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. @@ -34,27 +37,18 @@ struct TerminalSurface: NSViewRepresentable { /// set the appropriate title of the window/tab/split/etc. if they care. @Binding var title: String - @StateObject private var state: TerminalSurfaceView - - init(_ app: ghostty_app_t, hasFocus: Bool, size: CGSize, title: Binding) { - self._state = StateObject(wrappedValue: TerminalSurfaceView(app)) - self._title = title - self.hasFocus = hasFocus - self.size = size - } - 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. - state.delegate = context.coordinator - return state; + view.delegate = context.coordinator + return view; } func updateNSView(_ view: TerminalSurfaceView, context: Context) { - state.delegate = context.coordinator - state.focusDidChange(hasFocus) - state.sizeDidChange(size) + view.delegate = context.coordinator + view.focusDidChange(hasFocus) + view.sizeDidChange(size) } func makeCoordinator() -> Coordinator { @@ -81,11 +75,13 @@ protocol TerminalSurfaceDelegate: AnyObject { /// The actual NSView implementation for the terminal surface. class TerminalSurfaceView: NSView, NSTextInputClient, ObservableObject { + // This can be set to receive event callbacks. 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 = "" { + // 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 = "" { didSet { if let delegate = self.delegate { delegate.titleDidChange(to: title) @@ -93,8 +89,9 @@ class TerminalSurfaceView: NSView, NSTextInputClient, ObservableObject { } } - var surface: ghostty_surface_t? = nil + 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 @@ -103,123 +100,6 @@ class TerminalSurfaceView: NSView, NSTextInputClient, ObservableObject { // 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() @@ -483,4 +363,121 @@ class TerminalSurfaceView: NSView, NSTextInputClient, ObservableObject { 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, + ]; } diff --git a/macos/Sources/TerminalView.swift b/macos/Sources/TerminalView.swift index 830b3de68..6db2ff349 100644 --- a/macos/Sources/TerminalView.swift +++ b/macos/Sources/TerminalView.swift @@ -2,7 +2,9 @@ import SwiftUI import GhosttyKit struct TerminalView: View { - let app: ghostty_app_t + // The surface to create a view for + let surfaceView: TerminalSurfaceView + @FocusState private var surfaceFocus: Bool @Environment(\.isKeyWindow) private var isKeyWindow: Bool @State private var title: String = "Ghostty" @@ -11,12 +13,17 @@ struct TerminalView: View { // 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) + } + 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(app, hasFocus: hasFocus, size: geo.size, title: $title) + TerminalSurface(view: surfaceView, hasFocus: hasFocus, size: geo.size, title: $title) .focused($surfaceFocus) .navigationTitle(title) } @@ -54,7 +61,7 @@ struct TerminalSplittableView: View { Button("Split Vertical") { splitDirection = .vertical } } - TerminalView(app: app) + TerminalView(app) .focused($focusedSide, equals: .TopLeft) } case .horizontal: From 6854d09a8d48e78ea13ca832e148e09f2ddb7415 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Mar 2023 13:41:35 -0800 Subject: [PATCH 03/27] macos: get rid of delegates on our surface view --- macos/Sources/TerminalSurface.swift | 40 ++--------------------------- macos/Sources/TerminalView.swift | 7 +++-- 2 files changed, 5 insertions(+), 42 deletions(-) diff --git a/macos/Sources/TerminalSurface.swift b/macos/Sources/TerminalSurface.swift index d1817988b..1064b0e9f 100644 --- a/macos/Sources/TerminalSurface.swift +++ b/macos/Sources/TerminalSurface.swift @@ -33,62 +33,26 @@ struct TerminalSurface: NSViewRepresentable { /// 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 - 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. - view.delegate = context.coordinator return view; } func updateNSView(_ view: TerminalSurfaceView, context: Context) { - view.delegate = context.coordinator view.focusDidChange(hasFocus) view.sizeDidChange(size) } - - func makeCoordinator() -> Coordinator { - return Coordinator(self) - } - - class Coordinator : TerminalSurfaceDelegate { - let view: TerminalSurface - - init(_ view: TerminalSurface) { - 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: NSView, NSTextInputClient, ObservableObject { - // This can be set to receive event callbacks. - weak var delegate: TerminalSurfaceDelegate? - // 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 = "" { - didSet { - if let delegate = self.delegate { - delegate.titleDidChange(to: title) - } - } - } - + @Published var title: String = "" + private(set) var surface: ghostty_surface_t? var error: Error? = nil diff --git a/macos/Sources/TerminalView.swift b/macos/Sources/TerminalView.swift index 6db2ff349..4475aa70b 100644 --- a/macos/Sources/TerminalView.swift +++ b/macos/Sources/TerminalView.swift @@ -3,11 +3,10 @@ import GhosttyKit struct TerminalView: View { // The surface to create a view for - let surfaceView: TerminalSurfaceView + @ObservedObject var surfaceView: TerminalSurfaceView @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. @@ -23,9 +22,9 @@ struct TerminalView: View { // 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, title: $title) + TerminalSurface(view: surfaceView, hasFocus: hasFocus, size: geo.size) .focused($surfaceFocus) - .navigationTitle(title) + .navigationTitle(surfaceView.title) } } } From 00cf9edc94012803a06a15285d724150c15c2711 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 5 Mar 2023 14:23:25 -0800 Subject: [PATCH 04/27] macos: working on splits --- macos/Sources/GhosttyApp.swift | 2 +- macos/Sources/TerminalView.swift | 79 ++++++++++++++++++++++++++------ 2 files changed, 65 insertions(+), 16 deletions(-) diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index 1651c98f8..86b85f9af 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -21,7 +21,7 @@ struct GhosttyApp: App { case .error: ErrorView() case .ready: - TerminalSplittableView(app: ghostty.app!) + TerminalSplittableView(ghostty.app!) .modifier(WindowObservationModifier()) } }.commands { diff --git a/macos/Sources/TerminalView.swift b/macos/Sources/TerminalView.swift index 4475aa70b..5c15c71ec 100644 --- a/macos/Sources/TerminalView.swift +++ b/macos/Sources/TerminalView.swift @@ -2,7 +2,8 @@ import SwiftUI import GhosttyKit struct TerminalView: View { - // The surface to create a view for + // 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 @@ -17,6 +18,10 @@ struct TerminalView: View { 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 @@ -46,43 +51,87 @@ struct TerminalSplittableView: View { @FocusState private var focusedSide: Side? - let app: ghostty_app_t + let app: ghostty_app_t; + + @State private var topLeft: TerminalSurfaceView + + /// The bottom or right surface. This is private because in a splittable view it is only possible that we set + /// this, because it is triggered from a split event. + @State private var bottomRight: TerminalSurfaceView? = nil /// Direction of the current split. If this is "nil" then the terminal is not currently split at all. - @State var splitDirection: Direction = .none + @State private var splitDirection: Direction = .none + + init(_ app: ghostty_app_t) { + self.app = app + _topLeft = State(wrappedValue: TerminalSurfaceView(app)) + } + + init(_ app: ghostty_app_t, topLeft: TerminalSurfaceView) { + self.app = app + _topLeft = State(wrappedValue: topLeft) + } + + func split(to: Direction) { + assert(to != .none) + assert(splitDirection == .none) + splitDirection = to + bottomRight = TerminalSurfaceView(app) + } + + func closeTopLeft() { + assert(splitDirection != .none) + assert(bottomRight != nil) + topLeft = bottomRight! + splitDirection = .none + } + + func closeBottomRight() { + assert(splitDirection != .none) + assert(bottomRight != nil) + bottomRight = nil + splitDirection = .none + } var body: some View { switch (splitDirection) { case .none: VStack { HStack { - Button("Split Horizontal") { splitDirection = .horizontal } - Button("Split Vertical") { splitDirection = .vertical } + Button("Split Horizontal") { split(to: .horizontal) } + Button("Split Vertical") { split(to: .vertical) } } - TerminalView(app) + TerminalView(surface: topLeft) .focused($focusedSide, equals: .TopLeft) } case .horizontal: VStack { HStack { - Button("Close Left") { splitDirection = .none } - Button("Close Right") { splitDirection = .none } + Button("Close Left") { closeTopLeft() } + Button("Close Right") { closeBottomRight() } } HSplitView { - TerminalSplittableView(app: app) + TerminalSplittableView(app, topLeft: topLeft) .focused($focusedSide, equals: .TopLeft) - TerminalSplittableView(app: app) + TerminalSplittableView(app, topLeft: bottomRight!) .focused($focusedSide, equals: .BottomRight) } } case .vertical: - VSplitView { - TerminalSplittableView(app: app) - .focused($focusedSide, equals: .TopLeft) - TerminalSplittableView(app: app) - .focused($focusedSide, equals: .BottomRight) + VStack { + HStack { + Button("Close Top") { closeTopLeft() } + Button("Close Bottom") { closeBottomRight() } + } + + VSplitView { + TerminalSplittableView(app, topLeft: topLeft) + .focused($focusedSide, equals: .TopLeft) + TerminalSplittableView(app, topLeft: bottomRight!) + .focused($focusedSide, equals: .BottomRight) + } } } } From eef41aa6de151865e1220dea2558747c23240863 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Mar 2023 10:19:08 -0800 Subject: [PATCH 05/27] macos: window tracking cleans up observers properly --- macos/Sources/WindowTracker.swift | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/macos/Sources/WindowTracker.swift b/macos/Sources/WindowTracker.swift index c936db253..2bb5c83dd 100644 --- a/macos/Sources/WindowTracker.swift +++ b/macos/Sources/WindowTracker.swift @@ -37,18 +37,29 @@ class WindowObserver: ObservableObject { weak var window: NSWindow? { didSet { - self.isKeyWindow = window?.isKeyWindow ?? false - guard let window = window else { + // Always remove our previous observers if we have any + if let previous = self.becomeKeyobserver { + NotificationCenter.default.removeObserver(previous) self.becomeKeyobserver = nil + } + if let previous = self.resignKeyobserver { + NotificationCenter.default.removeObserver(previous) self.resignKeyobserver = nil + } + + // If our window is becoming nil then we clear everything + guard let window = window else { + self.isKeyWindow = false return } + self.isKeyWindow = window.isKeyWindow self.becomeKeyobserver = NotificationCenter.default.addObserver( forName: NSWindow.didBecomeKeyNotification, object: window, queue: .main ) { (n) in + print("KEY WINDOW YES") self.isKeyWindow = true } @@ -57,6 +68,7 @@ class WindowObserver: ObservableObject { object: window, queue: .main ) { (n) in + print("KEY WINDOW RESIGN") self.isKeyWindow = false } } From 1a4fabc2e5ef72dc54a0a7ca7bbc4222736f45d9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Mar 2023 10:40:08 -0800 Subject: [PATCH 06/27] macos: fix state handling of terminal surface in split --- macos/Sources/TerminalView.swift | 78 +++++++++++++++++++------------ macos/Sources/WindowTracker.swift | 2 - 2 files changed, 48 insertions(+), 32 deletions(-) diff --git a/macos/Sources/TerminalView.swift b/macos/Sources/TerminalView.swift index 5c15c71ec..d09d9cdbf 100644 --- a/macos/Sources/TerminalView.swift +++ b/macos/Sources/TerminalView.swift @@ -49,52 +49,70 @@ struct TerminalSplittableView: View { 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? - let app: ghostty_app_t; - - @State private var topLeft: TerminalSurfaceView - - /// The bottom or right surface. This is private because in a splittable view it is only possible that we set - /// this, because it is triggered from a split event. - @State private var bottomRight: TerminalSurfaceView? = nil - - /// Direction of the current split. If this is "nil" then the terminal is not currently split at all. - @State private var splitDirection: Direction = .none - init(_ app: ghostty_app_t) { self.app = app - _topLeft = State(wrappedValue: TerminalSurfaceView(app)) + _state = StateObject(wrappedValue: ViewState(app)) } init(_ app: ghostty_app_t, topLeft: TerminalSurfaceView) { self.app = app - _topLeft = State(wrappedValue: topLeft) + _state = StateObject(wrappedValue: ViewState(topLeft: topLeft)) } func split(to: Direction) { assert(to != .none) - assert(splitDirection == .none) - splitDirection = to - bottomRight = TerminalSurfaceView(app) + 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(splitDirection != .none) - assert(bottomRight != nil) - topLeft = bottomRight! - splitDirection = .none + assert(state.direction != .none) + assert(state.bottomRight != nil) + state.topLeft = state.bottomRight! + state.direction = .none } func closeBottomRight() { - assert(splitDirection != .none) - assert(bottomRight != nil) - bottomRight = nil - splitDirection = .none + assert(state.direction != .none) + assert(state.bottomRight != nil) + state.bottomRight = nil + state.direction = .none } var body: some View { - switch (splitDirection) { + switch (state.direction) { case .none: VStack { HStack { @@ -102,7 +120,7 @@ struct TerminalSplittableView: View { Button("Split Vertical") { split(to: .vertical) } } - TerminalView(surface: topLeft) + TerminalView(surface: state.topLeft) .focused($focusedSide, equals: .TopLeft) } case .horizontal: @@ -113,9 +131,9 @@ struct TerminalSplittableView: View { } HSplitView { - TerminalSplittableView(app, topLeft: topLeft) + TerminalSplittableView(app, topLeft: state.topLeft) .focused($focusedSide, equals: .TopLeft) - TerminalSplittableView(app, topLeft: bottomRight!) + TerminalSplittableView(app, topLeft: state.bottomRight!) .focused($focusedSide, equals: .BottomRight) } } @@ -127,9 +145,9 @@ struct TerminalSplittableView: View { } VSplitView { - TerminalSplittableView(app, topLeft: topLeft) + TerminalSplittableView(app, topLeft: state.topLeft) .focused($focusedSide, equals: .TopLeft) - TerminalSplittableView(app, topLeft: bottomRight!) + TerminalSplittableView(app, topLeft: state.bottomRight!) .focused($focusedSide, equals: .BottomRight) } } diff --git a/macos/Sources/WindowTracker.swift b/macos/Sources/WindowTracker.swift index 2bb5c83dd..54b53fc2e 100644 --- a/macos/Sources/WindowTracker.swift +++ b/macos/Sources/WindowTracker.swift @@ -59,7 +59,6 @@ class WindowObserver: ObservableObject { object: window, queue: .main ) { (n) in - print("KEY WINDOW YES") self.isKeyWindow = true } @@ -68,7 +67,6 @@ class WindowObserver: ObservableObject { object: window, queue: .main ) { (n) in - print("KEY WINDOW RESIGN") self.isKeyWindow = false } } From 1a3cd852f935e03928ea67419c7a4fd7e1207e1d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Mar 2023 21:28:09 -0800 Subject: [PATCH 07/27] macos: massive reorg --- macos/Ghostty.xcodeproj/project.pbxproj | 32 +- macos/Sources/Ghostty/AppState.swift | 147 +++++++ macos/Sources/Ghostty/Package.swift | 1 + macos/Sources/Ghostty/SplitView.swift | 136 +++++++ macos/Sources/Ghostty/SurfaceView.swift | 511 ++++++++++++++++++++++++ macos/Sources/GhosttyApp.swift | 127 +----- macos/Sources/TerminalSurface.swift | 447 --------------------- macos/Sources/TerminalView.swift | 156 -------- 8 files changed, 822 insertions(+), 735 deletions(-) create mode 100644 macos/Sources/Ghostty/AppState.swift create mode 100644 macos/Sources/Ghostty/Package.swift create mode 100644 macos/Sources/Ghostty/SplitView.swift create mode 100644 macos/Sources/Ghostty/SurfaceView.swift delete mode 100644 macos/Sources/TerminalSurface.swift delete mode 100644 macos/Sources/TerminalView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index e0446a021..20df07e2c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -7,11 +7,13 @@ objects = { /* 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 */; }; A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.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 */; }; A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyApp.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; @@ -19,11 +21,13 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - A507573D299FF33C009D7DC7 /* TerminalSurface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalSurface.swift; sourceTree = ""; }; - A518502329A197C700E4CC4F /* TerminalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalView.swift; sourceTree = ""; }; A518502529A1A45100E4CC4F /* WindowTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTracker.swift; sourceTree = ""; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; + A55B7BB529B6F47F0055DE60 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; + A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; + A55B7BBD29B701360055DE60 /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 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 = ""; }; @@ -48,17 +52,27 @@ isa = PBXGroup; children = ( A5D495A0299BEC2200DD1313 /* Preview Content */, + A55B7BB429B6F4410055DE60 /* Ghostty */, A5B30534299BEAAA0047F10C /* GhosttyApp.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, A59444F629A2ED5200725BBA /* SettingsView.swift */, - A518502329A197C700E4CC4F /* TerminalView.swift */, - A507573D299FF33C009D7DC7 /* TerminalSurface.swift */, A518502529A1A45100E4CC4F /* WindowTracker.swift */, ); path = Sources; sourceTree = ""; }; + A55B7BB429B6F4410055DE60 /* Ghostty */ = { + isa = PBXGroup; + children = ( + A55B7BB729B6F53A0055DE60 /* Package.swift */, + A55B7BB529B6F47F0055DE60 /* AppState.swift */, + A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */, + A55B7BBD29B701360055DE60 /* SplitView.swift */, + ); + path = Ghostty; + sourceTree = ""; + }; A5B30528299BEAAA0047F10C = { isa = PBXGroup; children = ( @@ -162,11 +176,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.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 */, - A507573E299FF33C009D7DC7 /* TerminalSurface.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */, ); diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift new file mode 100644 index 000000000..bc2c8eaa7 --- /dev/null +++ b/macos/Sources/Ghostty/AppState.swift @@ -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? { + 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?) { + 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.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?) { + let surfaceView = Unmanaged.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.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.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) + } +} diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift new file mode 100644 index 000000000..f170e9eb6 --- /dev/null +++ b/macos/Sources/Ghostty/Package.swift @@ -0,0 +1 @@ +struct Ghostty {} diff --git a/macos/Sources/Ghostty/SplitView.swift b/macos/Sources/Ghostty/SplitView.swift new file mode 100644 index 000000000..76effe619 --- /dev/null +++ b/macos/Sources/Ghostty/SplitView.swift @@ -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) + } + } + } + } + } + +} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift new file mode 100644 index 000000000..0e0f18b76 --- /dev/null +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -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) + } +} diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index 86b85f9af..dff4624fd 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -10,7 +10,7 @@ struct GhosttyApp: App { ) /// 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; var body: some Scene { @@ -21,7 +21,8 @@ struct GhosttyApp: App { case .error: ErrorView() case .ready: - TerminalSplittableView(ghostty.app!) + Ghostty.TerminalSplitView() + .ghosttyApp(ghostty.app!) .modifier(WindowObservationModifier()) } }.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? { - 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?) { - 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.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?) { - let surfaceView = Unmanaged.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.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.fromOpaque(app_ud).takeUnretainedValue() - } -} diff --git a/macos/Sources/TerminalSurface.swift b/macos/Sources/TerminalSurface.swift deleted file mode 100644 index 1064b0e9f..000000000 --- a/macos/Sources/TerminalSurface.swift +++ /dev/null @@ -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, - ]; -} diff --git a/macos/Sources/TerminalView.swift b/macos/Sources/TerminalView.swift deleted file mode 100644 index d09d9cdbf..000000000 --- a/macos/Sources/TerminalView.swift +++ /dev/null @@ -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) - } - } - } - } -} From 1faca5972ff94b2a5e124bb7a2878275557d44b8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 6 Mar 2023 21:53:22 -0800 Subject: [PATCH 08/27] macos: change key window detection --- macos/Ghostty.xcodeproj/project.pbxproj | 4 -- macos/Sources/Ghostty/SurfaceView.swift | 6 +- macos/Sources/GhosttyApp.swift | 1 - macos/Sources/WindowTracker.swift | 90 ------------------------- 4 files changed, 4 insertions(+), 97 deletions(-) delete mode 100644 macos/Sources/WindowTracker.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 20df07e2c..8e0846e29 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A518502529A1A45100E4CC4F /* WindowTracker.swift */; }; A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A535B9D9299C569B0017E2E4 /* ErrorView.swift */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB529B6F47F0055DE60 /* AppState.swift */; }; @@ -21,7 +20,6 @@ /* End PBXBuildFile section */ /* Begin PBXFileReference section */ - A518502529A1A45100E4CC4F /* WindowTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowTracker.swift; sourceTree = ""; }; A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A55B7BB529B6F47F0055DE60 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; @@ -57,7 +55,6 @@ A535B9D9299C569B0017E2E4 /* ErrorView.swift */, A55685DF29A03A9F004303CE /* AppError.swift */, A59444F629A2ED5200725BBA /* SettingsView.swift */, - A518502529A1A45100E4CC4F /* WindowTracker.swift */, ); path = Sources; sourceTree = ""; @@ -178,7 +175,6 @@ files = ( A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, - A518502629A1A45100E4CC4F /* WindowTracker.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, A55B7BBE29B701360055DE60 /* SplitView.swift in Sources */, A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */, diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 0e0f18b76..aad9f2a75 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -31,11 +31,13 @@ extension Ghostty { @ObservedObject var surfaceView: SurfaceView @FocusState private var surfaceFocus: Bool - @Environment(\.isKeyWindow) private var isKeyWindow: 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 && isKeyWindow } + 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 diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index dff4624fd..be2dff50b 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -23,7 +23,6 @@ struct GhosttyApp: App { case .ready: Ghostty.TerminalSplitView() .ghosttyApp(ghostty.app!) - .modifier(WindowObservationModifier()) } }.commands { CommandGroup(after: .newItem) { diff --git a/macos/Sources/WindowTracker.swift b/macos/Sources/WindowTracker.swift deleted file mode 100644 index 54b53fc2e..000000000 --- a/macos/Sources/WindowTracker.swift +++ /dev/null @@ -1,90 +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 { - // Always remove our previous observers if we have any - if let previous = self.becomeKeyobserver { - NotificationCenter.default.removeObserver(previous) - self.becomeKeyobserver = nil - } - if let previous = self.resignKeyobserver { - NotificationCenter.default.removeObserver(previous) - self.resignKeyobserver = nil - } - - // If our window is becoming nil then we clear everything - guard let window = window else { - self.isKeyWindow = false - return - } - - self.isKeyWindow = window.isKeyWindow - 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) {} -} From d00794de8ec10e6a9142ccc9c103ef37747dc9a2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 7 Mar 2023 16:22:00 -0800 Subject: [PATCH 09/27] macos: working on custom split view --- macos/Ghostty.xcodeproj/project.pbxproj | 24 ++++- ...plitView.swift => Ghostty.SplitView.swift} | 0 macos/Sources/GhosttyApp.swift | 11 ++- .../SplitView/SplitView.Splitter.swift | 67 ++++++++++++++ macos/Sources/SplitView/SplitView.swift | 87 +++++++++++++++++++ 5 files changed, 183 insertions(+), 6 deletions(-) rename macos/Sources/Ghostty/{SplitView.swift => Ghostty.SplitView.swift} (100%) create mode 100644 macos/Sources/SplitView/SplitView.Splitter.swift create mode 100644 macos/Sources/SplitView/SplitView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 8e0846e29..1debd502c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -12,10 +12,12 @@ 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 */; }; + A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyApp.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; + A5CEAFDE29B8058B00646FDA /* SplitView.Splitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Splitter.swift */; }; A5D495A2299BEC7E00DD1313 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; /* End PBXBuildFile section */ @@ -25,12 +27,14 @@ A55B7BB529B6F47F0055DE60 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; - A55B7BBD29B701360055DE60 /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; + A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.SplitView.swift; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 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 = ""; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; + A5CEAFDD29B8058B00646FDA /* SplitView.Splitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Splitter.swift; sourceTree = ""; }; A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ @@ -50,6 +54,7 @@ isa = PBXGroup; children = ( A5D495A0299BEC2200DD1313 /* Preview Content */, + A5CEAFDA29B8005900646FDA /* SplitView */, A55B7BB429B6F4410055DE60 /* Ghostty */, A5B30534299BEAAA0047F10C /* GhosttyApp.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, @@ -65,7 +70,7 @@ A55B7BB729B6F53A0055DE60 /* Package.swift */, A55B7BB529B6F47F0055DE60 /* AppState.swift */, A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */, - A55B7BBD29B701360055DE60 /* SplitView.swift */, + A55B7BBD29B701360055DE60 /* Ghostty.SplitView.swift */, ); path = Ghostty; sourceTree = ""; @@ -89,6 +94,15 @@ name = Products; sourceTree = ""; }; + A5CEAFDA29B8005900646FDA /* SplitView */ = { + isa = PBXGroup; + children = ( + A5CEAFDB29B8009000646FDA /* SplitView.swift */, + A5CEAFDD29B8058B00646FDA /* SplitView.Splitter.swift */, + ); + path = SplitView; + sourceTree = ""; + }; A5D495A0299BEC2200DD1313 /* Preview Content */ = { isa = PBXGroup; children = ( @@ -176,11 +190,13 @@ A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, - A55B7BBE29B701360055DE60 /* SplitView.swift in Sources */, + A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */, A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */, + A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */, + A5CEAFDE29B8058B00646FDA /* SplitView.Splitter.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macos/Sources/Ghostty/SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift similarity index 100% rename from macos/Sources/Ghostty/SplitView.swift rename to macos/Sources/Ghostty/Ghostty.SplitView.swift diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index be2dff50b..e170ede78 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -21,8 +21,15 @@ struct GhosttyApp: App { case .error: ErrorView() case .ready: - Ghostty.TerminalSplitView() - .ghosttyApp(ghostty.app!) + SplitView(.horizontal, left: { + Color.green + }, right: { + Color.red + }) +/* + Ghostty.Terminal() + .ghosttyApp(ghostty.app!) + */ } }.commands { CommandGroup(after: .newItem) { diff --git a/macos/Sources/SplitView/SplitView.Splitter.swift b/macos/Sources/SplitView/SplitView.Splitter.swift new file mode 100644 index 000000000..de2ac6628 --- /dev/null +++ b/macos/Sources/SplitView/SplitView.Splitter.swift @@ -0,0 +1,67 @@ +import SwiftUI + +extension SplitView { + struct Splitter: View { + let direction: Direction + 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() + } + } + } + } +} diff --git a/macos/Sources/SplitView/SplitView.swift b/macos/Sources/SplitView/SplitView.swift new file mode 100644 index 000000000..cce9463ab --- /dev/null +++ b/macos/Sources/SplitView/SplitView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct SplitView: View { + let direction: Direction + let left: L + let right: R + + private let splitterVisibleSize: CGFloat = 5 + private let splitterInvisibleSize: CGFloat = 5 + + @State var split: CGFloat = 0.5 + + 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) + Splitter(direction: direction, visibleSize: splitterVisibleSize, invisibleSize: splitterInvisibleSize) + .position(splitterPoint) + } + } + } + + 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 + } + + 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: + assert(false) + } + + return result + } + + 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) + } + } + + init(_ direction: Direction, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) { + self.direction = direction + self.left = left() + self.right = right() + } +} + +extension SplitView { + enum Direction { + case horizontal, vertical + } +} From 06d770fefabeaef63339b8ffa28490efc20ed0d1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 7 Mar 2023 16:34:38 -0800 Subject: [PATCH 10/27] macos: SplitView dragging --- macos/Sources/GhosttyApp.swift | 2 +- macos/Sources/SplitView/SplitView.swift | 24 ++++++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index e170ede78..ffca33c99 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -21,7 +21,7 @@ struct GhosttyApp: App { case .error: ErrorView() case .ready: - SplitView(.horizontal, left: { + SplitView(.vertical, left: { Color.green }, right: { Color.red diff --git a/macos/Sources/SplitView/SplitView.swift b/macos/Sources/SplitView/SplitView.swift index cce9463ab..e57979619 100644 --- a/macos/Sources/SplitView/SplitView.swift +++ b/macos/Sources/SplitView/SplitView.swift @@ -5,7 +5,7 @@ struct SplitView: View { let left: L let right: R - private let splitterVisibleSize: CGFloat = 5 + private let splitterVisibleSize: CGFloat = 2 private let splitterInvisibleSize: CGFloat = 5 @State var split: CGFloat = 0.5 @@ -25,10 +25,28 @@ struct SplitView: View { .offset(x: rightRect.origin.x, y: rightRect.origin.y) Splitter(direction: direction, visibleSize: splitterVisibleSize, invisibleSize: splitterInvisibleSize) .position(splitterPoint) + .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) } } } + 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 + } + } + } + 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) @@ -57,7 +75,9 @@ struct SplitView: View { result.size.width -= result.origin.x case .vertical: - assert(false) + result.origin.y += leftRect.size.height + result.origin.y += splitterVisibleSize / 2 + result.size.height -= result.origin.y } return result From 4bbb419cb01aadb377c287a856f784dd1ecbac0b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 7 Mar 2023 16:51:26 -0800 Subject: [PATCH 11/27] macos: use my new split view --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 10 ++++++---- macos/Sources/GhosttyApp.swift | 11 ++--------- macos/Sources/SplitView/SplitView.swift | 4 ++-- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 76effe619..402ad6564 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -108,12 +108,13 @@ extension Ghostty { Button("Close Right") { closeBottomRight() } } - HSplitView { + SplitView(.horizontal, left: { SplitViewChild(app, topLeft: state.topLeft) .focused($focusedSide, equals: .TopLeft) + }, right: { SplitViewChild(app, topLeft: state.bottomRight!) .focused($focusedSide, equals: .BottomRight) - } + }) } case .vertical: VStack { @@ -122,12 +123,13 @@ extension Ghostty { Button("Close Bottom") { closeBottomRight() } } - VSplitView { + SplitView(.vertical, left: { SplitViewChild(app, topLeft: state.topLeft) .focused($focusedSide, equals: .TopLeft) + }, right: { SplitViewChild(app, topLeft: state.bottomRight!) .focused($focusedSide, equals: .BottomRight) - } + }) } } } diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index ffca33c99..be2dff50b 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -21,15 +21,8 @@ struct GhosttyApp: App { case .error: ErrorView() case .ready: - SplitView(.vertical, left: { - Color.green - }, right: { - Color.red - }) -/* - Ghostty.Terminal() - .ghosttyApp(ghostty.app!) - */ + Ghostty.TerminalSplitView() + .ghosttyApp(ghostty.app!) } }.commands { CommandGroup(after: .newItem) { diff --git a/macos/Sources/SplitView/SplitView.swift b/macos/Sources/SplitView/SplitView.swift index e57979619..1afc6887a 100644 --- a/macos/Sources/SplitView/SplitView.swift +++ b/macos/Sources/SplitView/SplitView.swift @@ -5,8 +5,8 @@ struct SplitView: View { let left: L let right: R - private let splitterVisibleSize: CGFloat = 2 - private let splitterInvisibleSize: CGFloat = 5 + private let splitterVisibleSize: CGFloat = 1 + private let splitterInvisibleSize: CGFloat = 6 @State var split: CGFloat = 0.5 From e07a4e6892e0963912fdcbeb8b539d078d0aa482 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 7 Mar 2023 17:04:12 -0800 Subject: [PATCH 12/27] macos: comment my split view --- macos/Ghostty.xcodeproj/project.pbxproj | 8 ++-- macos/Sources/Ghostty/Ghostty.SplitView.swift | 16 ++++---- macos/Sources/GhosttyApp.swift | 2 +- ...Splitter.swift => SplitView.Divider.swift} | 3 +- macos/Sources/SplitView/SplitView.swift | 40 +++++++++++++------ 5 files changed, 43 insertions(+), 26 deletions(-) rename macos/Sources/SplitView/{SplitView.Splitter.swift => SplitView.Divider.swift} (94%) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 1debd502c..f97a91ae7 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -17,7 +17,7 @@ A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5B30534299BEAAA0047F10C /* GhosttyApp.swift */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDB29B8009000646FDA /* SplitView.swift */; }; - A5CEAFDE29B8058B00646FDA /* SplitView.Splitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Splitter.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 */; }; /* End PBXBuildFile section */ @@ -34,7 +34,7 @@ A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CEAFDB29B8009000646FDA /* SplitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.swift; sourceTree = ""; }; - A5CEAFDD29B8058B00646FDA /* SplitView.Splitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Splitter.swift; sourceTree = ""; }; + A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ @@ -98,7 +98,7 @@ isa = PBXGroup; children = ( A5CEAFDB29B8009000646FDA /* SplitView.swift */, - A5CEAFDD29B8058B00646FDA /* SplitView.Splitter.swift */, + A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */, ); path = SplitView; sourceTree = ""; @@ -196,7 +196,7 @@ A55685E029A03A9F004303CE /* AppError.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */, - A5CEAFDE29B8058B00646FDA /* SplitView.Splitter.swift in Sources */, + A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 402ad6564..e0fd35ec9 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -5,17 +5,17 @@ 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 { + struct TerminalSplit: View { @Environment(\.ghosttyApp) private var app var body: some View { if let app = app { - SplitViewChild(app) + TerminalSplitChild(app) } } } - private struct SplitViewChild: View { + private struct TerminalSplitChild: View { enum Direction { case none case vertical @@ -80,6 +80,7 @@ extension Ghostty { assert(state.bottomRight != nil) state.topLeft = state.bottomRight! state.direction = .none + focusedSide = .TopLeft } func closeBottomRight() { @@ -87,6 +88,7 @@ extension Ghostty { assert(state.bottomRight != nil) state.bottomRight = nil state.direction = .none + focusedSide = .TopLeft } var body: some View { @@ -109,10 +111,10 @@ extension Ghostty { } SplitView(.horizontal, left: { - SplitViewChild(app, topLeft: state.topLeft) + TerminalSplitChild(app, topLeft: state.topLeft) .focused($focusedSide, equals: .TopLeft) }, right: { - SplitViewChild(app, topLeft: state.bottomRight!) + TerminalSplitChild(app, topLeft: state.bottomRight!) .focused($focusedSide, equals: .BottomRight) }) } @@ -124,10 +126,10 @@ extension Ghostty { } SplitView(.vertical, left: { - SplitViewChild(app, topLeft: state.topLeft) + TerminalSplitChild(app, topLeft: state.topLeft) .focused($focusedSide, equals: .TopLeft) }, right: { - SplitViewChild(app, topLeft: state.bottomRight!) + TerminalSplitChild(app, topLeft: state.bottomRight!) .focused($focusedSide, equals: .BottomRight) }) } diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index be2dff50b..2eb459623 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -21,7 +21,7 @@ struct GhosttyApp: App { case .error: ErrorView() case .ready: - Ghostty.TerminalSplitView() + Ghostty.TerminalSplit() .ghosttyApp(ghostty.app!) } }.commands { diff --git a/macos/Sources/SplitView/SplitView.Splitter.swift b/macos/Sources/SplitView/SplitView.Divider.swift similarity index 94% rename from macos/Sources/SplitView/SplitView.Splitter.swift rename to macos/Sources/SplitView/SplitView.Divider.swift index de2ac6628..a5e3eb946 100644 --- a/macos/Sources/SplitView/SplitView.Splitter.swift +++ b/macos/Sources/SplitView/SplitView.Divider.swift @@ -1,7 +1,8 @@ import SwiftUI extension SplitView { - struct Splitter: View { + /// The split divider that is rendered and can be used to resize a split view. + struct Divider: View { let direction: Direction let visibleSize: CGFloat let invisibleSize: CGFloat diff --git a/macos/Sources/SplitView/SplitView.swift b/macos/Sources/SplitView/SplitView.swift index 1afc6887a..68973bdbb 100644 --- a/macos/Sources/SplitView/SplitView.swift +++ b/macos/Sources/SplitView/SplitView.swift @@ -1,15 +1,26 @@ 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: View { + /// Direction of the split let direction: Direction + + /// 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 - @State var split: CGFloat = 0.5 - var body: some View { GeometryReader { geo in let leftRect = self.leftRect(for: geo.size) @@ -23,14 +34,20 @@ struct SplitView: View { right .frame(width: rightRect.size.width, height: rightRect.size.height) .offset(x: rightRect.origin.x, y: rightRect.origin.y) - Splitter(direction: direction, visibleSize: splitterVisibleSize, invisibleSize: splitterInvisibleSize) + Divider(direction: direction, visibleSize: splitterVisibleSize, invisibleSize: splitterInvisibleSize) .position(splitterPoint) .gesture(dragGesture(geo.size, splitterPoint: splitterPoint)) } } } - func dragGesture(_ size: CGSize, splitterPoint: CGPoint) -> some Gesture { + init(_ direction: Direction, @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 @@ -47,7 +64,8 @@ struct SplitView: View { } } - func leftRect(for size: CGSize) -> CGRect { + /// 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) { @@ -63,7 +81,8 @@ struct SplitView: View { return result } - func rightRect(for size: CGSize, leftRect: CGRect) -> CGRect { + /// 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) { @@ -83,7 +102,8 @@ struct SplitView: View { return result } - func splitterPoint(for size: CGSize, leftRect: CGRect) -> CGPoint { + /// 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) @@ -92,12 +112,6 @@ struct SplitView: View { return CGPoint(x: size.width / 2, y: leftRect.size.height) } } - - init(_ direction: Direction, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) { - self.direction = direction - self.left = left() - self.right = right() - } } extension SplitView { From 508277f823e11213efb07c6cc89dfd50d00d3e86 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 7 Mar 2023 21:37:36 -0800 Subject: [PATCH 13/27] macos: fix focus on split change --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index e0fd35ec9..3e6397f7e 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -52,7 +52,6 @@ extension Ghostty { let app: ghostty_app_t @StateObject private var state: ViewState - @FocusState private var focusedSide: Side? init(_ app: ghostty_app_t) { self.app = app @@ -73,6 +72,9 @@ extension Ghostty { // Create the new split which always goes to the bottom right. state.bottomRight = Ghostty.SurfaceView(app) + + // See fixFocus comment, we have to run this whenever split changes. + fixFocus() } func closeTopLeft() { @@ -80,7 +82,9 @@ extension Ghostty { assert(state.bottomRight != nil) state.topLeft = state.bottomRight! state.direction = .none - focusedSide = .TopLeft + + // See fixFocus comment, we have to run this whenever split changes. + fixFocus() } func closeBottomRight() { @@ -88,7 +92,26 @@ extension Ghostty { assert(state.bottomRight != nil) state.bottomRight = nil state.direction = .none - focusedSide = .TopLeft + + // See fixFocus comment, we have to run this whenever split changes. + fixFocus() + } + + /// 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. + private func fixFocus() { + 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 = state.topLeft.window else { + self.fixFocus() + return + } + + window.makeFirstResponder(state.topLeft) + } } var body: some View { @@ -97,11 +120,12 @@ extension Ghostty { VStack { HStack { Button("Split Horizontal") { split(to: .horizontal) } + .keyboardShortcut("d", modifiers: .command) Button("Split Vertical") { split(to: .vertical) } + .keyboardShortcut("d", modifiers: [.command, .shift]) } SurfaceWrapper(surfaceView: state.topLeft) - .focused($focusedSide, equals: .TopLeft) } case .horizontal: VStack { @@ -112,10 +136,8 @@ extension Ghostty { SplitView(.horizontal, left: { TerminalSplitChild(app, topLeft: state.topLeft) - .focused($focusedSide, equals: .TopLeft) }, right: { TerminalSplitChild(app, topLeft: state.bottomRight!) - .focused($focusedSide, equals: .BottomRight) }) } case .vertical: @@ -127,10 +149,8 @@ extension Ghostty { SplitView(.vertical, left: { TerminalSplitChild(app, topLeft: state.topLeft) - .focused($focusedSide, equals: .TopLeft) }, right: { TerminalSplitChild(app, topLeft: state.bottomRight!) - .focused($focusedSide, equals: .BottomRight) }) } } From a754fe8c304675ade2496ddb0e253f5c15c7a4bd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 7 Mar 2023 22:09:39 -0800 Subject: [PATCH 14/27] macos: little tweaks --- macos/Sources/Ghostty/SurfaceView.swift | 15 +++++++++++++++ macos/Sources/GhosttyApp.swift | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index aad9f2a75..4c3def0c2 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -46,6 +46,7 @@ extension Ghostty { GeometryReader { geo in Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size) .focused($surfaceFocus) + .focusedValue(\.ghosttySurfaceView, surfaceView) .navigationTitle(surfaceView.title) } .ghosttySurfaceView(surfaceView) @@ -511,3 +512,17 @@ extension 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 + } +} + diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index 2eb459623..76fe6a1d5 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -11,7 +11,7 @@ struct GhosttyApp: App { /// The ghostty global state. Only one per process. @StateObject private var ghostty = Ghostty.AppState() - @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate; + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate var body: some Scene { WindowGroup { From 15b7e7fcd783459834015597aa651232631c8a7f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Mar 2023 08:43:42 -0800 Subject: [PATCH 15/27] termio: coalesce resize events On macOS, we were seeing resize events dropped by child processes if too many SIGWNCH events were generated. --- src/termio/Thread.zig | 68 +++++++++++++++++++++++++++++++++++++++++- src/termio/message.zig | 8 +++-- 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/src/termio/Thread.zig b/src/termio/Thread.zig index 49ae661cc..b1c03cc6e 100644 --- a/src/termio/Thread.zig +++ b/src/termio/Thread.zig @@ -18,6 +18,15 @@ const log = std.log.scoped(.io_thread); /// the future if we want it configurable. 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 alloc: std.mem.Allocator, @@ -34,6 +43,12 @@ wakeup_c: xev.Completion = .{}, stop: xev.Async, 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. impl: *termio.Impl, @@ -60,6 +75,10 @@ pub fn init( var stop_h = try xev.Async.init(); 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 var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); @@ -69,6 +88,7 @@ pub fn init( .loop = loop, .wakeup = wakeup_h, .stop = stop_h, + .coalesce = coalesce_h, .impl = impl, .mailbox = mailbox, }; @@ -77,6 +97,7 @@ pub fn init( /// Clean up the thread. This is only safe to call once the thread /// completes executing; the caller must join prior to this. pub fn deinit(self: *Thread) void { + self.coalesce.deinit(); self.stop.deinit(); self.wakeup.deinit(); self.loop.deinit(); @@ -129,7 +150,7 @@ fn drainMailbox(self: *Thread) !void { log.debug("mailbox message={}", .{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), .write_small => |v| try self.impl.queueWrite(v.data[0..v.len]), .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( self_: ?*Thread, _: *xev.Loop, diff --git a/src/termio/message.zig b/src/termio/message.zig index a9f9a9006..d1a75bc01 100644 --- a/src/termio/message.zig +++ b/src/termio/message.zig @@ -15,8 +15,7 @@ pub const Message = union(enum) { /// in the future. pub const WriteReq = MessageData(u8, 38); - /// Resize the window. - resize: struct { + pub const Resize = struct { /// The grid size for the given screen size with padding applied. grid_size: renderer.GridSize, @@ -27,7 +26,10 @@ pub const Message = union(enum) { /// The padding, so that the terminal implementation can subtract /// this to send to the pty. padding: renderer.Padding, - }, + }; + + /// Resize the window. + resize: Resize, /// Clear the screen. clear_screen: struct { From 8ce6f349f882c9ef86b674214f09a9be395af504 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Mar 2023 08:56:17 -0800 Subject: [PATCH 16/27] input: new_split binding, can parse enums --- src/Surface.zig | 6 ++++++ src/config.zig | 10 +++++++++ src/input/Binding.zig | 47 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 211a3cedc..b2a7a0998 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -940,6 +940,12 @@ pub fn keyCallback( } 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_window => { _ = self.app_mailbox.push(.{ .close = self }, .{ .instant = {} }); }, diff --git a/src/config.zig b/src/config.zig index 06adb6910..0e8762bad 100644 --- a/src/config.zig +++ b/src/config.zig @@ -296,6 +296,16 @@ pub const Config = struct { .{ .key = .right_bracket, .mods = .{ .super = true, .shift = true } }, .{ .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 const start = @enumToInt(inputpkg.Key.one); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index a788d0c5e..2fb9f0534 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -105,7 +105,20 @@ pub fn parse(input: []const u8) !Binding { // Cursor keys can't be set currently 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,6 +180,10 @@ pub const Action = union(enum) { /// Go to the tab with the specific number, 1-indexed. goto_tab: usize, + /// Create a new split in the given direction. The new split will appear + /// in the direction given. + new_split: SplitDirection, + /// Close the current window or tab close_window: void, @@ -177,6 +194,13 @@ pub const Action = union(enum) { normal: []const u8, application: []const u8, }; + + pub const SplitDirection = enum { + right, + down, + + // Note: we don't support top or left yet + }; }; /// Trigger is the associated key state that can trigger an action. @@ -286,11 +310,15 @@ test "parse: triggers" { try testing.expectError(Error.InvalidFormat, parse("a+b=ignore")); } -test "parse: action" { +test "parse: action invalid" { const testing = std.testing; // invalid action try testing.expectError(Error.InvalidFormat, parse("a=nopenopenope")); +} + +test "parse: action no parameters" { + const testing = std.testing; // no parameters try testing.expectEqual( @@ -298,6 +326,10 @@ test "parse: action" { try parse("a=ignore"), ); try testing.expectError(Error.InvalidFormat, parse("a=ignore:A")); +} + +test "parse: action with string" { + const testing = std.testing; // parameter { @@ -306,3 +338,14 @@ test "parse: action" { 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); + } +} From fa9ee0815f124b61a0589677ecb793e2492b7a26 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Mar 2023 14:56:50 -0800 Subject: [PATCH 17/27] apprt/embedded: newSplit callback --- include/ghostty.h | 7 ++ macos/Sources/Ghostty/AppState.swift | 18 ++++- macos/Sources/Ghostty/Ghostty.SplitView.swift | 72 +++++++++---------- macos/Sources/Ghostty/Package.swift | 5 +- macos/Sources/Ghostty/SurfaceView.swift | 20 +++++- src/apprt/embedded.zig | 13 ++++ src/input.zig | 1 + src/input/Binding.zig | 4 +- 8 files changed, 99 insertions(+), 41 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 3302db522..b93f44ea4 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -30,6 +30,7 @@ typedef void (*ghostty_runtime_wakeup_cb)(void *); typedef void (*ghostty_runtime_set_title_cb)(void *, const char *); typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *); typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *); +typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e); typedef struct { void *userdata; @@ -37,6 +38,7 @@ typedef struct { ghostty_runtime_set_title_cb set_title_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb; ghostty_runtime_write_clipboard_cb write_clipboard_cb; + ghostty_runtime_new_split_cb new_split_cb; } ghostty_runtime_config_s; typedef struct { @@ -45,6 +47,11 @@ typedef struct { double scale_factor; } ghostty_surface_config_s; +typedef enum { + GHOSTTY_SPLIT_RIGHT, + GHOSTTY_SPLIT_DOWN +} ghostty_split_direction_e; + typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index bc2c8eaa7..2e40e3aef 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -56,7 +56,9 @@ extension Ghostty { 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) }) + 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))) } + ) // Create the ghostty app. guard let app = ghostty_app_new(&runtime_cfg, cfg) else { @@ -81,6 +83,13 @@ extension Ghostty { // 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 readClipboard(_ userdata: UnsafeMutableRawPointer?) -> UnsafePointer? { guard let appState = self.appState(fromSurface: userdata) else { return nil } guard let str = NSPasteboard.general.string(forType: .string) else { return nil } @@ -117,13 +126,18 @@ extension Ghostty { } /// Returns the GhosttyState from the given userdata value. - static func appState(fromSurface userdata: UnsafeMutableRawPointer?) -> AppState? { + static private func appState(fromSurface userdata: UnsafeMutableRawPointer?) -> AppState? { let surfaceView = Unmanaged.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.fromOpaque(app_ud).takeUnretainedValue() } + + /// Returns the surface view from the userdata. + static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView? { + return Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + } } } diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 3e6397f7e..edc580ba4 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -103,56 +103,56 @@ extension Ghostty { /// that should have it. private func fixFocus() { DispatchQueue.main.async { + // The view we want to focus + var view = state.topLeft + if let right = state.bottomRight { view = right } + // 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 = state.topLeft.window else { + guard let window = view.window else { self.fixFocus() return } - window.makeFirstResponder(state.topLeft) + _ = state.topLeft.resignFirstResponder() + _ = state.bottomRight?.resignFirstResponder() + window.makeFirstResponder(view) + } + } + + private func onNewSplit(notification: SwiftUI.Notification) { + guard let directionAny = notification.userInfo?["direction"] else { return } + guard let direction = directionAny as? ghostty_split_direction_e else { return } + switch (direction) { + case GHOSTTY_SPLIT_RIGHT: + split(to: .horizontal) + + case GHOSTTY_SPLIT_DOWN: + split(to: .vertical) + + default: + break } } var body: some View { switch (state.direction) { case .none: - VStack { - HStack { - Button("Split Horizontal") { split(to: .horizontal) } - .keyboardShortcut("d", modifiers: .command) - Button("Split Vertical") { split(to: .vertical) } - .keyboardShortcut("d", modifiers: [.command, .shift]) - } - - SurfaceWrapper(surfaceView: state.topLeft) - } + let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyNewSplit, object: state.topLeft) + SurfaceWrapper(surfaceView: state.topLeft) + .onReceive(pub) { onNewSplit(notification: $0) } case .horizontal: - VStack { - HStack { - Button("Close Left") { closeTopLeft() } - Button("Close Right") { closeBottomRight() } - } - - SplitView(.horizontal, left: { - TerminalSplitChild(app, topLeft: state.topLeft) - }, right: { - TerminalSplitChild(app, topLeft: state.bottomRight!) - }) - } + SplitView(.horizontal, left: { + TerminalSplitChild(app, topLeft: state.topLeft) + }, right: { + TerminalSplitChild(app, topLeft: state.bottomRight!) + }) case .vertical: - VStack { - HStack { - Button("Close Top") { closeTopLeft() } - Button("Close Bottom") { closeBottomRight() } - } - - SplitView(.vertical, left: { - TerminalSplitChild(app, topLeft: state.topLeft) - }, right: { - TerminalSplitChild(app, topLeft: state.bottomRight!) - }) - } + SplitView(.vertical, left: { + TerminalSplitChild(app, topLeft: state.topLeft) + }, right: { + TerminalSplitChild(app, topLeft: state.bottomRight!) + }) } } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index f170e9eb6..b673549e5 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -1 +1,4 @@ -struct Ghostty {} +struct Ghostty { + // All the notifications that will be emitted will be put here. + struct Notification {} +} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 4c3def0c2..2d7584d69 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -107,7 +107,7 @@ extension Ghostty { // 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 + // 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 } @@ -161,6 +161,16 @@ extension Ghostty { 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) } @@ -494,6 +504,14 @@ extension Ghostty { } +// 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") +} + // MARK: Surface Environment Keys private struct GhosttySurfaceViewKey: EnvironmentKey { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 0a21504e7..946fad24f 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -44,6 +44,10 @@ pub const App = struct { /// Write the clipboard value. 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.Binding.Action.SplitDirection) callconv(.C) void = null, }; core_app: *CoreApp, @@ -148,6 +152,15 @@ pub const Surface = struct { 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 getContentScale(self: *const Surface) !apprt.ContentScale { return self.content_scale; } diff --git a/src/input.zig b/src/input.zig index da121890f..2bdcbe86a 100644 --- a/src/input.zig +++ b/src/input.zig @@ -3,6 +3,7 @@ const std = @import("std"); pub usingnamespace @import("input/mouse.zig"); pub usingnamespace @import("input/key.zig"); pub const Binding = @import("input/Binding.zig"); +pub const SplitDirection = Binding.Action.SplitDirection; test { std.testing.refAllDecls(@This()); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 2fb9f0534..ecae7495b 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -195,7 +195,9 @@ pub const Action = union(enum) { application: []const u8, }; - pub const SplitDirection = enum { + // 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, From 6c857877e8f164b03cedceb0981689cb6863d9b4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Mar 2023 15:05:15 -0800 Subject: [PATCH 18/27] apprt/embedded: close surface callback --- include/ghostty.h | 2 ++ macos/Sources/Ghostty/AppState.swift | 8 +++++++- macos/Sources/Ghostty/Ghostty.SplitView.swift | 8 ++++++++ macos/Sources/Ghostty/SurfaceView.swift | 3 +++ src/Surface.zig | 6 ++++++ src/apprt/embedded.zig | 14 +++++++++++++- src/config.zig | 4 ++-- src/input/Binding.zig | 6 +++++- 8 files changed, 46 insertions(+), 5 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index b93f44ea4..206e123dc 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -31,6 +31,7 @@ typedef void (*ghostty_runtime_set_title_cb)(void *, const char *); typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *); 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 { void *userdata; @@ -39,6 +40,7 @@ typedef struct { ghostty_runtime_read_clipboard_cb read_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; typedef struct { diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 2e40e3aef..b6b4dcb5e 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -57,7 +57,8 @@ extension Ghostty { 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))) } + 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. @@ -90,6 +91,11 @@ extension Ghostty { ]) } + 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? { guard let appState = self.appState(fromSurface: userdata) else { return nil } guard let str = NSPasteboard.general.string(forType: .string) else { return nil } diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index edc580ba4..d33aebd14 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -143,15 +143,23 @@ extension Ghostty { .onReceive(pub) { onNewSplit(notification: $0) } case .horizontal: SplitView(.horizontal, left: { + let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.topLeft) TerminalSplitChild(app, topLeft: state.topLeft) + .onReceive(pub) { _ in closeTopLeft() } }, right: { + let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.bottomRight!) TerminalSplitChild(app, topLeft: state.bottomRight!) + .onReceive(pub) { _ in closeBottomRight() } }) case .vertical: SplitView(.vertical, left: { + let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.topLeft) TerminalSplitChild(app, topLeft: state.topLeft) + .onReceive(pub) { _ in closeTopLeft() } }, right: { + let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.bottomRight!) TerminalSplitChild(app, topLeft: state.bottomRight!) + .onReceive(pub) { _ in closeBottomRight() } }) } } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 2d7584d69..3c1079885 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -510,6 +510,9 @@ 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 diff --git a/src/Surface.zig b/src/Surface.zig index b2a7a0998..b3e7693cd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -946,6 +946,12 @@ pub fn keyCallback( } 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 => { _ = self.app_mailbox.push(.{ .close = self }, .{ .instant = {} }); }, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 946fad24f..8e739a2cc 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -47,7 +47,10 @@ pub const App = struct { /// Create a new split view. If the embedder doesn't support split /// views then this can be null. - new_split: ?*const fn (SurfaceUD, input.Binding.Action.SplitDirection) callconv(.C) void = 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, @@ -161,6 +164,15 @@ pub const Surface = struct { 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 { return self.content_scale; } diff --git a/src/config.zig b/src/config.zig index 0e8762bad..a9d74c27c 100644 --- a/src/config.zig +++ b/src/config.zig @@ -278,8 +278,8 @@ pub const Config = struct { ); try result.keybind.set.put( alloc, - .{ .key = .w, .mods = .{ .super = true } }, - .{ .close_window = {} }, + .{ .key = .w, .mods = .{ .super = true, .shift = true } }, + .{ .close_surface = {} }, ); try result.keybind.set.put( alloc, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index ecae7495b..3eeb941a6 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -184,7 +184,11 @@ pub const Action = union(enum) { /// in the direction given. new_split: SplitDirection, - /// Close the current window or tab + /// 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, /// Quit ghostty From c0315e72f16601c140bdcf0a5c9d70058187fab2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Mar 2023 15:08:42 -0800 Subject: [PATCH 19/27] macos: nil bottomright when we close it --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index d33aebd14..e2af77be3 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -81,6 +81,7 @@ extension Ghostty { assert(state.direction != .none) assert(state.bottomRight != nil) state.topLeft = state.bottomRight! + state.bottomRight = nil state.direction = .none // See fixFocus comment, we have to run this whenever split changes. From 0388dc35bb82ea8cdc729f566fad45ed83cd7e9d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Mar 2023 15:26:58 -0800 Subject: [PATCH 20/27] macos: set proper window title for focused split --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 7 ++----- macos/Sources/Ghostty/SurfaceView.swift | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index e2af77be3..2b0f1a29e 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -7,10 +7,12 @@ extension Ghostty { /// split direction by splitting the terminal. struct TerminalSplit: View { @Environment(\.ghosttyApp) private var app + @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? var body: some View { if let app = app { TerminalSplitChild(app) + .navigationTitle(surfaceTitle ?? "Ghostty") } } } @@ -22,11 +24,6 @@ extension Ghostty { case horizontal } - enum Side: Hashable { - case TopLeft - case BottomRight - } - /// The stored state between invocations. class ViewState: ObservableObject { /// The direction of the split currently diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 3c1079885..76c123fcd 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -5,10 +5,12 @@ 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 { TerminalForApp(app) + .navigationTitle(surfaceTitle ?? "Ghostty") } } } @@ -46,8 +48,7 @@ extension Ghostty { GeometryReader { geo in Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size) .focused($surfaceFocus) - .focusedValue(\.ghosttySurfaceView, surfaceView) - .navigationTitle(surfaceView.title) + .focusedValue(\.ghosttySurfaceTitle, surfaceView.title) } .ghosttySurfaceView(surfaceView) } @@ -547,3 +548,14 @@ extension FocusedValues { } } +extension FocusedValues { + var ghosttySurfaceTitle: String? { + get { self[FocusedGhosttySurfaceTitle.self] } + set { self[FocusedGhosttySurfaceTitle.self] = newValue } + } + + struct FocusedGhosttySurfaceTitle: FocusedValueKey { + typealias Value = String + } +} + From 31378bcaa5e03f3b141130bab1ee4c3aee4a0d4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Mar 2023 22:14:29 -0800 Subject: [PATCH 21/27] macos: redo all the split views --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 208 +++++++++--------- macos/Sources/Ghostty/SurfaceView.swift | 16 +- .../Sources/SplitView/SplitView.Divider.swift | 2 +- macos/Sources/SplitView/SplitView.swift | 10 +- 4 files changed, 124 insertions(+), 112 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 2b0f1a29e..1e49f968e 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -11,90 +11,143 @@ extension Ghostty { var body: some View { if let app = app { - TerminalSplitChild(app) + TerminalSplitContainer(app: app) .navigationTitle(surfaceTitle ?? "Ghostty") } } } - private struct TerminalSplitChild: View { - enum Direction { - case none - case vertical - case horizontal + private struct TerminalSplitPane: View { + @ObservedObject var surfaceView: SurfaceView + @Binding var requestSplit: SplitViewDirection? + @Binding var requestClose: Bool + + var body: some View { + let pub = NotificationCenter.default.publisher(for: Notification.ghosttyNewSplit, object: surfaceView) + let pubClose = NotificationCenter.default.publisher(for: Notification.ghosttyCloseSurface, object: surfaceView) + SurfaceWrapper(surfaceView: surfaceView) + .onReceive(pub) { onNewSplit(notification: $0) } + .onReceive(pubClose) { _ in requestClose = true } } - /// 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 + private func onNewSplit(notification: SwiftUI.Notification) { + guard let directionAny = notification.userInfo?["direction"] else { return } + guard let direction = directionAny as? ghostty_split_direction_e else { return } + switch (direction) { + case GHOSTTY_SPLIT_RIGHT: + requestSplit = .horizontal + + case GHOSTTY_SPLIT_DOWN: + requestSplit = .vertical + + default: + break + } + } + } + + private struct TerminalSplitContainer: View { + let app: ghostty_app_t + var parentClose: Binding? = nil + @State private var direction: SplitViewDirection? = nil + @State private var proposedDirection: SplitViewDirection? = nil + @State private var closeTopLeft: Bool = false + @State private var closeBottomRight: Bool = false + @StateObject private var panes: PaneState + + class PaneState: ObservableObject { + @Published var topLeft: SurfaceView + @Published var bottomRight: 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) + self.topLeft = 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) { + init(topLeft: SurfaceView) { self.topLeft = topLeft } } - let app: ghostty_app_t - @StateObject private var state: ViewState - - init(_ app: ghostty_app_t) { + init(app: ghostty_app_t) { self.app = app - _state = StateObject(wrappedValue: ViewState(app)) + _panes = StateObject(wrappedValue: PaneState(app)) } - init(_ app: ghostty_app_t, topLeft: Ghostty.SurfaceView) { + init(app: ghostty_app_t, parentClose: Binding, topLeft: SurfaceView) { self.app = app - _state = StateObject(wrappedValue: ViewState(topLeft: topLeft)) + self.parentClose = parentClose + _panes = StateObject(wrappedValue: PaneState(topLeft: topLeft)) + } + + var body: some View { + if let direction = self.direction { + SplitView(direction, left: { + TerminalSplitContainer( + app: app, + parentClose: $closeTopLeft, + topLeft: panes.topLeft + ) + .onChange(of: closeTopLeft) { value in + guard value else { return } + + // Move our bottom to our top and reset all of our state + panes.topLeft = panes.bottomRight! + panes.bottomRight = nil + self.direction = nil + closeTopLeft = false + closeBottomRight = false + + // See fixFocus comment, we have to run this whenever split changes. + fixFocus() + } + }, right: { + TerminalSplitContainer( + app: app, + parentClose: $closeBottomRight, + topLeft: panes.bottomRight! + ) + .onChange(of: closeBottomRight) { value in + guard value else { return } + + // Move our bottom to our top and reset all of our state + panes.bottomRight = nil + self.direction = nil + closeTopLeft = false + closeBottomRight = false + + // See fixFocus comment, we have to run this whenever split changes. + fixFocus() + } + }) + } else { + TerminalSplitPane(surfaceView: panes.topLeft, requestSplit: $proposedDirection, requestClose: $closeTopLeft) + .onChange(of: proposedDirection) { value in + guard let newDirection = value else { return } + split(to: newDirection) + } + .onChange(of: closeTopLeft) { value in + guard value else { return } + self.parentClose?.wrappedValue = value + } + } } - func split(to: Direction) { - assert(to != .none) - assert(state.direction == .none) + private func split(to: SplitViewDirection) { + assert(direction == nil) // Make the split the desired value - state.direction = to + direction = to // Create the new split which always goes to the bottom right. - state.bottomRight = Ghostty.SurfaceView(app) + panes.bottomRight = SurfaceView(app) // See fixFocus comment, we have to run this whenever split changes. fixFocus() } - func closeTopLeft() { - assert(state.direction != .none) - assert(state.bottomRight != nil) - state.topLeft = state.bottomRight! - state.bottomRight = nil - state.direction = .none - - // See fixFocus comment, we have to run this whenever split changes. - fixFocus() - } - - func closeBottomRight() { - assert(state.direction != .none) - assert(state.bottomRight != nil) - state.bottomRight = nil - state.direction = .none - - // See fixFocus comment, we have to run this whenever split changes. - fixFocus() - } - /// 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 @@ -102,8 +155,8 @@ extension Ghostty { private func fixFocus() { DispatchQueue.main.async { // The view we want to focus - var view = state.topLeft - if let right = state.bottomRight { view = right } + var view = panes.topLeft + if let right = panes.bottomRight { view = right } // If the callback runs before the surface is attached to a view // then the window will be nil. We just reschedule in that case. @@ -112,55 +165,10 @@ extension Ghostty { return } - _ = state.topLeft.resignFirstResponder() - _ = state.bottomRight?.resignFirstResponder() + _ = panes.topLeft.resignFirstResponder() + _ = panes.bottomRight?.resignFirstResponder() window.makeFirstResponder(view) } } - - private func onNewSplit(notification: SwiftUI.Notification) { - guard let directionAny = notification.userInfo?["direction"] else { return } - guard let direction = directionAny as? ghostty_split_direction_e else { return } - switch (direction) { - case GHOSTTY_SPLIT_RIGHT: - split(to: .horizontal) - - case GHOSTTY_SPLIT_DOWN: - split(to: .vertical) - - default: - break - } - } - - var body: some View { - switch (state.direction) { - case .none: - let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyNewSplit, object: state.topLeft) - SurfaceWrapper(surfaceView: state.topLeft) - .onReceive(pub) { onNewSplit(notification: $0) } - case .horizontal: - SplitView(.horizontal, left: { - let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.topLeft) - TerminalSplitChild(app, topLeft: state.topLeft) - .onReceive(pub) { _ in closeTopLeft() } - }, right: { - let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.bottomRight!) - TerminalSplitChild(app, topLeft: state.bottomRight!) - .onReceive(pub) { _ in closeBottomRight() } - }) - case .vertical: - SplitView(.vertical, left: { - let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.topLeft) - TerminalSplitChild(app, topLeft: state.topLeft) - .onReceive(pub) { _ in closeTopLeft() } - }, right: { - let pub = NotificationCenter.default.publisher(for: Ghostty.Notification.ghosttyCloseSurface, object: state.bottomRight!) - TerminalSplitChild(app, topLeft: state.bottomRight!) - .onReceive(pub) { _ in closeBottomRight() } - }) - } - } } - } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 76c123fcd..776c762a9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -9,21 +9,27 @@ extension Ghostty { var body: some View { if let app = self.app { - TerminalForApp(app) - .navigationTitle(surfaceTitle ?? "Ghostty") + SurfaceForApp(app) { surfaceView in + SurfaceWrapper(surfaceView: surfaceView) + } + .navigationTitle(surfaceTitle ?? "Ghostty") } } } - private struct TerminalForApp: View { + /// Yields a SurfaceView for a ghostty app that can then be used however you want. + struct SurfaceForApp: View { + let content: ((SurfaceView) -> Content) + @StateObject private var surfaceView: SurfaceView - init(_ app: ghostty_app_t) { + init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) { _surfaceView = StateObject(wrappedValue: SurfaceView(app)) + self.content = content } var body: some View { - SurfaceWrapper(surfaceView: surfaceView) + content(surfaceView) } } diff --git a/macos/Sources/SplitView/SplitView.Divider.swift b/macos/Sources/SplitView/SplitView.Divider.swift index a5e3eb946..aba1c48f4 100644 --- a/macos/Sources/SplitView/SplitView.Divider.swift +++ b/macos/Sources/SplitView/SplitView.Divider.swift @@ -3,7 +3,7 @@ import SwiftUI extension SplitView { /// The split divider that is rendered and can be used to resize a split view. struct Divider: View { - let direction: Direction + let direction: SplitViewDirection let visibleSize: CGFloat let invisibleSize: CGFloat diff --git a/macos/Sources/SplitView/SplitView.swift b/macos/Sources/SplitView/SplitView.swift index 68973bdbb..c28b6b578 100644 --- a/macos/Sources/SplitView/SplitView.swift +++ b/macos/Sources/SplitView/SplitView.swift @@ -7,7 +7,7 @@ import SwiftUI /// as time goes on. For example, the splitter divider size and styling is all hardcoded. struct SplitView: View { /// Direction of the split - let direction: Direction + let direction: SplitViewDirection /// The left and right views to render. let left: L @@ -41,7 +41,7 @@ struct SplitView: View { } } - init(_ direction: Direction, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) { + init(_ direction: SplitViewDirection, @ViewBuilder left: (() -> L), @ViewBuilder right: (() -> R)) { self.direction = direction self.left = left() self.right = right() @@ -114,8 +114,6 @@ struct SplitView: View { } } -extension SplitView { - enum Direction { - case horizontal, vertical - } +enum SplitViewDirection { + case horizontal, vertical } From bfbd7f1c1bb902c27db24d25e3cb6fce1a3acec8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Mar 2023 11:07:12 -0800 Subject: [PATCH 22/27] macos: terminal split views handle nested close properly --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 292 +++++++++++------- 1 file changed, 173 insertions(+), 119 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 1e49f968e..bdfd9c2b1 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -11,164 +11,218 @@ extension Ghostty { var body: some View { if let app = app { - TerminalSplitContainer(app: app) + TerminalSplitRoot(app: app) .navigationTitle(surfaceTitle ?? "Ghostty") } } } - private struct TerminalSplitPane: View { - @ObservedObject var surfaceView: SurfaceView - @Binding var requestSplit: SplitViewDirection? - @Binding var requestClose: Bool - + /// 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 + @Published var parent: SplitNode? = nil + + /// 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 + + /// This is an ignored value because at the root we can't close. + @State private var ignoredRequestClose: Bool = false + + init(app: ghostty_app_t) { + _node = State(initialValue: SplitNode.noSplit(.init(app))) + } + var body: some View { - let pub = NotificationCenter.default.publisher(for: Notification.ghosttyNewSplit, object: surfaceView) - let pubClose = NotificationCenter.default.publisher(for: Notification.ghosttyCloseSurface, object: surfaceView) - SurfaceWrapper(surfaceView: surfaceView) + switch (node) { + case .noSplit(let leaf): + TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $ignoredRequestClose) + + case .horizontal(let container): + TerminalSplitContainer(direction: .horizontal, node: $node, container: container) + + case .vertical(let container): + TerminalSplitContainer(direction: .vertical, node: $node, container: container) + } + } + } + + /// 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: - requestSplit = .horizontal + splitDirection = .horizontal case GHOSTTY_SPLIT_DOWN: - requestSplit = .vertical + splitDirection = .vertical default: - break - } - } - } - - private struct TerminalSplitContainer: View { - let app: ghostty_app_t - var parentClose: Binding? = nil - @State private var direction: SplitViewDirection? = nil - @State private var proposedDirection: SplitViewDirection? = nil - @State private var closeTopLeft: Bool = false - @State private var closeBottomRight: Bool = false - @StateObject private var panes: PaneState - - class PaneState: ObservableObject { - @Published var topLeft: SurfaceView - @Published var bottomRight: 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 = SurfaceView(app) + return } - /// 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: SurfaceView) { - self.topLeft = topLeft - } - } - - init(app: ghostty_app_t) { - self.app = app - _panes = StateObject(wrappedValue: PaneState(app)) - } - - init(app: ghostty_app_t, parentClose: Binding, topLeft: SurfaceView) { - self.app = app - self.parentClose = parentClose - _panes = StateObject(wrappedValue: PaneState(topLeft: topLeft)) - } - - var body: some View { - if let direction = self.direction { - SplitView(direction, left: { - TerminalSplitContainer( - app: app, - parentClose: $closeTopLeft, - topLeft: panes.topLeft - ) - .onChange(of: closeTopLeft) { value in - guard value else { return } - - // Move our bottom to our top and reset all of our state - panes.topLeft = panes.bottomRight! - panes.bottomRight = nil - self.direction = nil - closeTopLeft = false - closeBottomRight = false - - // See fixFocus comment, we have to run this whenever split changes. - fixFocus() - } - }, right: { - TerminalSplitContainer( - app: app, - parentClose: $closeBottomRight, - topLeft: panes.bottomRight! - ) - .onChange(of: closeBottomRight) { value in - guard value else { return } - - // Move our bottom to our top and reset all of our state - panes.bottomRight = nil - self.direction = nil - closeTopLeft = false - closeBottomRight = false - - // See fixFocus comment, we have to run this whenever split changes. - fixFocus() - } - }) - } else { - TerminalSplitPane(surfaceView: panes.topLeft, requestSplit: $proposedDirection, requestClose: $closeTopLeft) - .onChange(of: proposedDirection) { value in - guard let newDirection = value else { return } - split(to: newDirection) - } - .onChange(of: closeTopLeft) { value in - guard value else { return } - self.parentClose?.wrappedValue = value - } - } - } - - private func split(to: SplitViewDirection) { - assert(direction == nil) + // Setup our new container since we are now split + let container = SplitNode.Container(from: leaf) - // Make the split the desired value - direction = to + // 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) + } - // Create the new split which always goes to the bottom right. - panes.bottomRight = SurfaceView(app) - // See fixFocus comment, we have to run this whenever split changes. - fixFocus() + Self.fixFocus(container.bottomRight) } /// 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. - private func fixFocus() { + fileprivate static func fixFocus(_ target: SplitNode) { + let view = target.preferredFocus() + DispatchQueue.main.async { - // The view we want to focus - var view = panes.topLeft - if let right = panes.bottomRight { view = right } - // 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() + self.fixFocus(target) return } - _ = panes.topLeft.resignFirstResponder() - _ = panes.bottomRight?.resignFirstResponder() window.makeFirstResponder(view) } } } + + /// 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) + } + }, 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) + } + }) + } + } + + /// 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) + } + } + } } From dc6e5e143704c92df9f3c21e2e23cff7425eac2b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Mar 2023 11:40:47 -0800 Subject: [PATCH 23/27] macos: fix bug where like... 5 terminals were being launched in the background --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 30 ++++++++++--------- src/Surface.zig | 1 + 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index bdfd9c2b1..47a8be4f2 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -7,12 +7,10 @@ extension Ghostty { /// split direction by splitting the terminal. struct TerminalSplit: View { @Environment(\.ghosttyApp) private var app - @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? - + var body: some View { if let app = app { TerminalSplitRoot(app: app) - .navigationTitle(surfaceTitle ?? "Ghostty") } } } @@ -49,7 +47,6 @@ extension Ghostty { class Leaf: ObservableObject { let app: ghostty_app_t @Published var surface: SurfaceView - @Published var parent: SplitNode? = nil /// Initialize a new leaf which creates a new terminal surface. init(_ app: ghostty_app_t) { @@ -85,21 +82,26 @@ extension Ghostty { /// This is an ignored value because at the root we can't close. @State private var ignoredRequestClose: Bool = false + @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? + init(app: ghostty_app_t) { - _node = State(initialValue: SplitNode.noSplit(.init(app))) + _node = State(wrappedValue: SplitNode.noSplit(.init(app))) } var body: some View { - switch (node) { - case .noSplit(let leaf): - TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $ignoredRequestClose) - - case .horizontal(let container): - TerminalSplitContainer(direction: .horizontal, node: $node, container: container) - - case .vertical(let container): - TerminalSplitContainer(direction: .vertical, node: $node, container: container) + ZStack { + switch (node) { + case .noSplit(let leaf): + TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $ignoredRequestClose) + + 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") } } diff --git a/src/Surface.zig b/src/Surface.zig index b3e7693cd..f2452b85b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -476,6 +476,7 @@ pub fn deinit(self: *Surface) void { self.alloc.destroy(self.font_group); 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 From a265e7ce207dd8a5ea33bc8f80f105af12b5bbe4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Mar 2023 14:27:55 -0800 Subject: [PATCH 24/27] macos: take over menu bar, separate close and close window --- include/ghostty.h | 1 + macos/Sources/Ghostty/AppState.swift | 6 +++ macos/Sources/Ghostty/SurfaceView.swift | 1 + macos/Sources/GhosttyApp.swift | 67 ++++++++++++++++++++++++- src/apprt/embedded.zig | 6 +++ 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/include/ghostty.h b/include/ghostty.h index 206e123dc..c7f4b549e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -252,6 +252,7 @@ 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_scroll(ghostty_surface_t, double, double); void ghostty_surface_ime_point(ghostty_surface_t, double *, double *); +void ghostty_surface_request_close(ghostty_surface_t); #ifdef __cplusplus } diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index b6b4dcb5e..819c9a378 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -82,6 +82,12 @@ extension Ghostty { 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) + } + // MARK: Ghostty Callbacks static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e) { diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 776c762a9..2f76fce11 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -55,6 +55,7 @@ extension Ghostty { Surface(view: surfaceView, hasFocus: hasFocus, size: geo.size) .focused($surfaceFocus) .focusedValue(\.ghosttySurfaceTitle, surfaceView.title) + .focusedValue(\.ghosttySurfaceView, surfaceView) } .ghosttySurfaceView(surfaceView) } diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index 76fe6a1d5..b41d6188c 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -13,6 +13,9 @@ struct GhosttyApp: App { @StateObject private var ghostty = Ghostty.AppState() @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + /// The current focused Ghostty surface in this app + @FocusedValue(\.ghosttySurfaceView) private var focusedSurface + var body: some Scene { WindowGroup { switch ghostty.readiness { @@ -27,7 +30,10 @@ struct GhosttyApp: App { }.commands { CommandGroup(after: .newItem) { Button("New Tab", action: newTab).keyboardShortcut("t", modifiers: [.command]) - } + Divider() + Button("Close", action: close).keyboardShortcut("w", modifiers: [.command]) + Button("Close Window", action: closeWindow).keyboardShortcut("w", modifiers: [.command, .shift]) + } } Settings { @@ -44,13 +50,72 @@ struct GhosttyApp: App { currentWindow.addTabbedWindow(newWindow, ordered: .above) } } + + func close() { + guard let surfaceView = focusedSurface else { return } + guard let surface = surfaceView.surface else { return } + ghostty.requestClose(surface: surface) + } + + func closeWindow() { + guard let currentWindow = NSApp.keyWindow else { return } + currentWindow.close() + } } class AppDelegate: NSObject, NSApplicationDelegate { + // See CursedMenuManager for more information. + private var menuManager: CursedMenuManager? + func applicationDidFinishLaunching(_ notification: Notification) { UserDefaults.standard.register(defaults: [ // Disable this so that repeated key events make it through to our terminal views. "ApplePressAndHoldEnabled": false, ]) + + // Create our menu manager to create some custom menu items that + // we can't create from SwiftUI. + menuManager = CursedMenuManager() + } +} + +/// SwiftUI as of macOS 13.x provides no way to manage the default menu items that are created +/// as part of a WindowGroup. This class is prefixed with "Cursed" because this is a truly cursed +/// 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? + + init() { + // If the whole menu changed we want to setup our new KVO + self.mainToken = NSApp.observe(\.mainMenu, options: .new) { app, change in + self.onNewMenu() + } + + // Initial setup + onNewMenu() + } + + private func onNewMenu() { + guard let menu = NSApp.mainMenu else { return } + guard let file = menu.item(withTitle: "File") else { return } + guard let submenu = file.submenu else { return } + fileToken = submenu.observe(\.items) { (_, _) in + let remove = ["Close", "Close All"] + + // 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. + let items = submenu.items.reversed() + remove.forEach { title in + if let item = items.first(where: { $0.title.caseInsensitiveCompare(title) == .orderedSame }) { + submenu.removeItem(item) + } + } + } } } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 8e739a2cc..8cf5d6d30 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -470,4 +470,10 @@ pub const CAPI = struct { x.* = pos.x; 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 {}; + } }; From 0aadd192827a78ce5eb63bdeb6ed0180fb70b1b6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Mar 2023 14:44:33 -0800 Subject: [PATCH 25/27] macos: close surface works --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 20 ++++++++++++------- macos/Sources/GhosttyApp.swift | 18 ++++++++--------- src/config.zig | 7 ++++++- 3 files changed, 28 insertions(+), 17 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 47a8be4f2..25113f8cf 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -7,10 +7,11 @@ extension Ghostty { /// 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) + TerminalSplitRoot(app: app, onClose: onClose) } } } @@ -78,13 +79,13 @@ extension Ghostty { /// one of these in a split tree. private struct TerminalSplitRoot: View { @State private var node: SplitNode - - /// This is an ignored value because at the root we can't close. - @State private var ignoredRequestClose: Bool = false + @State private var requestClose: Bool = false + let onClose: (() -> Void)? @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? - init(app: ghostty_app_t) { + init(app: ghostty_app_t, onClose: (() ->Void)? = nil) { + self.onClose = onClose _node = State(wrappedValue: SplitNode.noSplit(.init(app))) } @@ -92,7 +93,12 @@ extension Ghostty { ZStack { switch (node) { case .noSplit(let leaf): - TerminalSplitLeaf(leaf: leaf, node: $node, requestClose: $ignoredRequestClose) + 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) diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index b41d6188c..f096f1028 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -24,15 +24,15 @@ struct GhosttyApp: App { case .error: ErrorView() case .ready: - Ghostty.TerminalSplit() + Ghostty.TerminalSplit(onClose: Self.closeWindow) .ghosttyApp(ghostty.app!) } }.commands { CommandGroup(after: .newItem) { - Button("New Tab", action: newTab).keyboardShortcut("t", modifiers: [.command]) + Button("New Tab", action: Self.newTab).keyboardShortcut("t", modifiers: [.command]) Divider() Button("Close", action: close).keyboardShortcut("w", modifiers: [.command]) - Button("Close Window", action: closeWindow).keyboardShortcut("w", modifiers: [.command, .shift]) + Button("Close Window", action: Self.closeWindow).keyboardShortcut("w", modifiers: [.command, .shift]) } } @@ -42,7 +42,7 @@ struct GhosttyApp: App { } // Create a new tab in the currently active window - func newTab() { + static func newTab() { guard let currentWindow = NSApp.keyWindow else { return } guard let windowController = currentWindow.windowController else { return } windowController.newWindowForTab(nil) @@ -51,16 +51,16 @@ struct GhosttyApp: App { } } + 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 closeWindow() { - guard let currentWindow = NSApp.keyWindow else { return } - currentWindow.close() - } } class AppDelegate: NSObject, NSApplicationDelegate { diff --git a/src/config.zig b/src/config.zig index a9d74c27c..2b6b3766f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -278,9 +278,14 @@ pub const Config = struct { ); try result.keybind.set.put( alloc, - .{ .key = .w, .mods = .{ .super = true, .shift = true } }, + .{ .key = .w, .mods = .{ .super = true } }, .{ .close_surface = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .w, .mods = .{ .super = true, .shift = true } }, + .{ .close_window = {} }, + ); try result.keybind.set.put( alloc, .{ .key = .t, .mods = .{ .super = true } }, From f85c1c256c4ccf8cc34d8282163f0588193d225a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Mar 2023 15:24:45 -0800 Subject: [PATCH 26/27] macos: menu bar to split --- include/ghostty.h | 1 + macos/Sources/Ghostty/AppState.swift | 4 ++++ macos/Sources/GhosttyApp.swift | 15 +++++++++++++++ src/apprt/embedded.zig | 5 +++++ 4 files changed, 25 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index c7f4b549e..ea62ca74b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -253,6 +253,7 @@ void ghostty_surface_mouse_pos(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_request_close(ghostty_surface_t); +void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e); #ifdef __cplusplus } diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 819c9a378..59f68785b 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -88,6 +88,10 @@ extension Ghostty { 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) { diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index f096f1028..4a14e6fe6 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -31,6 +31,9 @@ struct GhosttyApp: App { CommandGroup(after: .newItem) { 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]) } @@ -61,6 +64,18 @@ struct GhosttyApp: App { 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 { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 8cf5d6d30..7ed69a514 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -476,4 +476,9 @@ pub const CAPI = struct { 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 {}; + } }; From a356c6210528158552e9bb273c2eb299d3a0e98a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Mar 2023 15:31:48 -0800 Subject: [PATCH 27/27] macos: properly lose focus on previous split when new split --- macos/Sources/Ghostty/Ghostty.SplitView.swift | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index 25113f8cf..8dbbfd9bc 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -159,25 +159,34 @@ extension Ghostty { } // See fixFocus comment, we have to run this whenever split changes. - Self.fixFocus(container.bottomRight) + 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) { + 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) + 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() + } } } } @@ -199,7 +208,7 @@ extension Ghostty { // When closing the topLeft, our parent becomes the bottomRight. node = container.bottomRight - TerminalSplitLeaf.fixFocus(node) + TerminalSplitLeaf.fixFocus(node, previous: container.topLeft) } }, right: { TerminalSplitNested(node: $container.bottomRight, requestClose: $closeBottomRight) @@ -208,7 +217,7 @@ extension Ghostty { // When closing the bottomRight, our parent becomes the topLeft. node = container.topLeft - TerminalSplitLeaf.fixFocus(node) + TerminalSplitLeaf.fixFocus(node, previous: container.bottomRight) } }) }