diff --git a/include/ghostty.h b/include/ghostty.h index 163044c1b..70428c89f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -238,6 +238,7 @@ 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 *, bool); typedef void (*ghostty_runtime_focus_split_cb)(void *, ghostty_split_focus_direction_e); +typedef void (*ghostty_runtime_goto_tab_cb)(void *, int32_t); typedef struct { void *userdata; @@ -249,6 +250,7 @@ typedef struct { ghostty_runtime_new_split_cb new_split_cb; ghostty_runtime_close_surface_cb close_surface_cb; ghostty_runtime_focus_split_cb focus_split_cb; + ghostty_runtime_goto_tab_cb goto_tab_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- diff --git a/macos/Sources/ContentView.swift b/macos/Sources/ContentView.swift index eb938cc2e..d53951d47 100644 --- a/macos/Sources/ContentView.swift +++ b/macos/Sources/ContentView.swift @@ -26,6 +26,9 @@ struct ContentView: View { NSApplication.shared.reply(toApplicationShouldTerminate: true) } case .ready: + let center = NotificationCenter.default + let gotoTab = center.publisher(for: Ghostty.Notification.ghosttyGotoTab) + let confirmQuitting = Binding(get: { self.appDelegate.confirmQuit && (self.window?.isKeyWindow ?? false) }, set: { @@ -35,6 +38,7 @@ struct ContentView: View { Ghostty.TerminalSplit(onClose: Self.closeWindow) .ghosttyApp(ghostty.app!) .background(WindowAccessor(window: $window)) + .onReceive(gotoTab) { onGotoTab(notification: $0) } .confirmationDialog( "Quit Ghostty?", isPresented: confirmQuitting) { @@ -57,4 +61,27 @@ struct ContentView: View { guard let currentWindow = NSApp.keyWindow else { return } currentWindow.close() } + + private func onGotoTab(notification: SwiftUI.Notification) { + // Notification center indiscriminately sends to every subscriber (makes sense) + // but we only want to process this once. In order to process it once lets only + // handle it if we're the focused window. + guard let window = self.window else { return } + guard window.isKeyWindow else { return } + + // Get the tab index from the notification + guard let tabIndexAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return } + guard let tabIndex = tabIndexAny as? Int32 else { return } + + guard let windowController = window.windowController else { return } + guard let tabGroup = windowController.window?.tabGroup else { return } + let tabbedWindows = tabGroup.windows + + // Tabs are 0-indexed here, so we subtract one from the key the user hit. + let adjustedIndex = Int(tabIndex - 1); + guard adjustedIndex >= 0 && adjustedIndex < tabbedWindows.count else { return } + + let targetWindow = tabbedWindows[adjustedIndex] + targetWindow.makeKeyAndOrderFront(nil) + } } diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 4a76e9a9d..6a8e3ec5c 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -61,7 +61,8 @@ extension Ghostty { write_clipboard_cb: { userdata, str in AppState.writeClipboard(userdata, string: str) }, new_split_cb: { userdata, direction in AppState.newSplit(userdata, direction: direction) }, close_surface_cb: { userdata, processAlive in AppState.closeSurface(userdata, processAlive: processAlive) }, - focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) } + focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) }, + goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) } ) // Create the ghostty app. @@ -157,6 +158,17 @@ extension Ghostty { ) } + static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) { + guard let surface = self.surfaceUserdata(from: userdata) else { return } + NotificationCenter.default.post( + name: Notification.ghosttyGotoTab, + object: surface, + userInfo: [ + Notification.GotoTabKey: n, + ] + ) + } + 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/Package.swift b/macos/Sources/Ghostty/Package.swift index 535fa1e67..e3c9c5a1f 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -74,6 +74,10 @@ extension Ghostty.Notification { /// Focus previous/next split. Has a SplitFocusDirection in the userinfo. static let ghosttyFocusSplit = Notification.Name("com.mitchellh.ghostty.focusSplit") static let SplitDirectionKey = ghosttyFocusSplit.rawValue + + /// Goto tab. Has tab index in the userinfo. + static let ghosttyGotoTab = Notification.Name("com.mitchellh.ghostty.gotoTab") + static let GotoTabKey = ghosttyGotoTab.rawValue } // Make the input enum hashable. diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index c5a1e7fa1..85845dd02 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -20,7 +20,9 @@ struct GhosttyApp: App { WindowGroup { ContentView(ghostty: ghostty) } + .backport.defaultSize(width: 800, height: 600) + .commands { CommandGroup(after: .newItem) { Button("New Tab", action: Self.newTab).keyboardShortcut("t", modifiers: [.command]) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 43886df57..7289b2401 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -61,6 +61,9 @@ pub const App = struct { /// Focus the previous/next split (if any). focus_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null, + + /// Goto tab + goto_tab: ?*const fn (SurfaceUD, usize) callconv(.C) void = null, }; core_app: *CoreApp, @@ -359,6 +362,15 @@ pub const Surface = struct { }; } + pub fn gotoTab(self: *Surface, n: usize) void { + const func = self.app.opts.goto_tab orelse { + log.info("runtime embedder does not goto_tab", .{}); + return; + }; + + func(self.opts.userdata, n); + } + /// The cursor position from the host directly is in screen coordinates but /// all our interface works in pixels. fn cursorPosToPixels(self: *const Surface, pos: apprt.CursorPos) !apprt.CursorPos {