diff --git a/include/ghostty.h b/include/ghostty.h index af3aa1fa7..a6fc51d9b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -233,6 +233,7 @@ typedef enum { typedef enum { GHOSTTY_BINDING_COPY_TO_CLIPBOARD, GHOSTTY_BINDING_PASTE_FROM_CLIPBOARD, + GHOSTTY_BINDING_NEW_TAB, } ghostty_binding_action_e; // Fully defined types. This MUST be kept in sync with equivalent Zig @@ -242,6 +243,7 @@ typedef struct { void *userdata; void *nsview; double scale_factor; + uint16_t font_size; } ghostty_surface_config_s; typedef void (*ghostty_runtime_wakeup_cb)(void *); @@ -250,6 +252,7 @@ typedef void (*ghostty_runtime_set_title_cb)(void *, const char *); typedef const char* (*ghostty_runtime_read_clipboard_cb)(void *, ghostty_clipboard_e); typedef void (*ghostty_runtime_write_clipboard_cb)(void *, const char *, ghostty_clipboard_e); typedef void (*ghostty_runtime_new_split_cb)(void *, ghostty_split_direction_e); +typedef void (*ghostty_runtime_new_tab_cb)(void *, ghostty_surface_config_s); 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); @@ -264,6 +267,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_new_tab_cb new_tab_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; @@ -289,6 +293,8 @@ bool ghostty_app_tick(ghostty_app_t); void *ghostty_app_userdata(ghostty_app_t); void ghostty_app_keyboard_changed(ghostty_app_t); +ghostty_surface_config_s ghostty_surface_config_new(); + ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*); void ghostty_surface_free(ghostty_surface_t); ghostty_app_t ghostty_surface_app(ghostty_surface_t); diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 066401056..fa468221d 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -81,11 +81,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } @IBAction func newTab(_ sender: Any?) { - if let existingWindow = windowManager.mainWindow { - windowManager.addNewTab(to: existingWindow) - } else { - windowManager.addNewWindow() - } + windowManager.newTab() } @IBAction func closeWindow(_ sender: Any) { diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift index 221c50078..b62e630b7 100644 --- a/macos/Sources/Features/Primary Window/PrimaryView.swift +++ b/macos/Sources/Features/Primary Window/PrimaryView.swift @@ -11,6 +11,9 @@ struct PrimaryView: View { // We need this to report back up the app controller which surface in this view is focused. let focusedSurfaceWrapper: FocusedSurfaceWrapper + // If this is set, this is the base configuration that we build our surface out of. + let baseConfig: ghostty_surface_config_s? + // We need access to our window to know if we're the key window to determine // if we show the quit confirmation or not. @State private var window: NSWindow? @@ -71,7 +74,7 @@ struct PrimaryView: View { self.appDelegate.confirmQuit = $0 }) - Ghostty.TerminalSplit(onClose: Self.closeWindow) + Ghostty.TerminalSplit(onClose: Self.closeWindow, baseConfig: self.baseConfig) .ghosttyApp(ghostty.app!) .background(WindowAccessor(window: $window)) .onReceive(gotoTab) { onGotoTab(notification: $0) } diff --git a/macos/Sources/Features/Primary Window/PrimaryWindow.swift b/macos/Sources/Features/Primary Window/PrimaryWindow.swift index 85ca49601..6f6e0ba73 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindow.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindow.swift @@ -16,7 +16,7 @@ class FocusedSurfaceWrapper { class PrimaryWindow: NSWindow { var focusedSurfaceWrapper: FocusedSurfaceWrapper = FocusedSurfaceWrapper() - static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate) -> PrimaryWindow { + static func create(ghostty: Ghostty.AppState, appDelegate: AppDelegate, baseConfig: ghostty_surface_config_s? = nil) -> PrimaryWindow { let window = PrimaryWindow( contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), styleMask: [.titled, .closable, .miniaturizable, .resizable], @@ -27,7 +27,9 @@ class PrimaryWindow: NSWindow { window.contentView = NSHostingView(rootView: PrimaryView( ghostty: ghostty, appDelegate: appDelegate, - focusedSurfaceWrapper: window.focusedSurfaceWrapper)) + focusedSurfaceWrapper: window.focusedSurfaceWrapper, + baseConfig: baseConfig + )) // We do want to cascade when new windows are created window.windowController?.shouldCascadeWindows = true diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift index ffc7d12f5..a77aebf28 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift @@ -7,8 +7,8 @@ class PrimaryWindowController: NSWindowController { // This is required for the "+" button to show up in the tab bar to add a // new tab. override func newWindowForTab(_ sender: Any?) { - guard let window = self.window else { preconditionFailure("Expected window to be loaded") } + guard let window = self.window as? PrimaryWindow else { preconditionFailure("Expected window to be loaded") } guard let manager = self.windowManager else { return } - manager.addNewTab(to: window) + manager.triggerNewTab(for: window) } } diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift index 16b241070..987dc86a2 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift @@ -1,5 +1,7 @@ import Cocoa import Combine +import GhosttyKit +import SwiftUI // PrimaryWindowManager manages the windows and tabs in the primary window // of the application. It keeps references to windows and cleans them up when @@ -43,6 +45,22 @@ class PrimaryWindowManager { init(ghostty: Ghostty.AppState) { self.ghostty = ghostty + + // Register self as observer for the NewTab notification that + // is triggered via callback from Zig code. + NotificationCenter.default.addObserver( + self, + selector: #selector(onNewTab), + name: Ghostty.Notification.ghosttyNewTab, + object: nil) + } + + deinit { + // Clean up the observer. + NotificationCenter.default.removeObserver( + self, + name: Ghostty.Notification.ghosttyNewTab, + object: nil) } /// Add the initial window for the application. This should only be called once from the AppDelegate. @@ -61,16 +79,41 @@ class PrimaryWindowManager { newWindow.makeKeyAndOrderFront(nil) } - func addNewTab(to window: NSWindow) { - guard let controller = createWindowController() else { return } + // triggerNewTab tells the Zig core code to create a new tab, which then calls + // back into Swift code. + func triggerNewTab(for window: PrimaryWindow) { + guard let surface = window.focusedSurfaceWrapper.surface else { return } + ghostty.newTab(surface: surface) + } + + func newTab() { + if let window = mainWindow as? PrimaryWindow { + self.triggerNewTab(for: window) + } else { + self.addNewWindow() + } + } + + @objc private func onNewTab(notification: SwiftUI.Notification) { + guard let surfaceView = notification.object as? Ghostty.SurfaceView else { return } + guard let window = surfaceView.window else { return } + + let configAny = notification.userInfo?[Ghostty.Notification.NewTabKey] + let config = configAny as? ghostty_surface_config_s + + self.addNewTab(to: window, withBaseConfig: config) + } + + private func addNewTab(to window: NSWindow, withBaseConfig config: ghostty_surface_config_s? = nil) { + guard let controller = createWindowController(withBaseConfig: config) else { return } guard let newWindow = addManagedWindow(windowController: controller)?.window else { return } window.addTabbedWindow(newWindow, ordered: .above) newWindow.makeKeyAndOrderFront(nil) } - private func createWindowController() -> PrimaryWindowController? { + private func createWindowController(withBaseConfig config: ghostty_surface_config_s? = nil) -> PrimaryWindowController? { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return nil } - let window = PrimaryWindow.create(ghostty: ghostty, appDelegate: appDelegate) + let window = PrimaryWindow.create(ghostty: ghostty, appDelegate: appDelegate, baseConfig: config) Self.lastCascadePoint = window.cascadeTopLeft(from: Self.lastCascadePoint) let controller = PrimaryWindowController(window: window) controller.windowManager = self diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 68076c224..1a0a17f4b 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -61,6 +61,7 @@ extension Ghostty { read_clipboard_cb: { userdata, loc in AppState.readClipboard(userdata, location: loc) }, write_clipboard_cb: { userdata, str, loc in AppState.writeClipboard(userdata, string: str, location: loc) }, new_split_cb: { userdata, direction in AppState.newSplit(userdata, direction: direction) }, + new_tab_cb: { userdata, surfaceConfig in AppState.newTab(userdata, config: surfaceConfig) }, close_surface_cb: { userdata, processAlive in AppState.closeSurface(userdata, processAlive: processAlive) }, focus_split_cb: { userdata, direction in AppState.focusSplit(userdata, direction: direction) }, goto_tab_cb: { userdata, n in AppState.gotoTab(userdata, n: n) }, @@ -137,6 +138,10 @@ extension Ghostty { ghostty_surface_request_close(surface) } + func newTab(surface: ghostty_surface_t) { + ghostty_surface_binding_action(surface, GHOSTTY_BINDING_NEW_TAB, nil) + } + func split(surface: ghostty_surface_t, direction: ghostty_split_direction_e) { ghostty_surface_split(surface, direction) } @@ -258,6 +263,19 @@ extension Ghostty { ) } + static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { + guard let surface = self.surfaceUserdata(from: userdata) else { return } + + var userInfo: [AnyHashable : Any] = [:]; + userInfo[Notification.NewTabKey] = config; + + NotificationCenter.default.post( + name: Notification.ghosttyNewTab, + object: surface, + userInfo: userInfo + ) + } + /// Returns the GhosttyState from the given userdata value. static private func appState(fromSurface userdata: UnsafeMutableRawPointer?) -> AppState? { let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() diff --git a/macos/Sources/Ghostty/Ghostty.SplitView.swift b/macos/Sources/Ghostty/Ghostty.SplitView.swift index cc0f72487..a3e224139 100644 --- a/macos/Sources/Ghostty/Ghostty.SplitView.swift +++ b/macos/Sources/Ghostty/Ghostty.SplitView.swift @@ -8,10 +8,11 @@ extension Ghostty { struct TerminalSplit: View { @Environment(\.ghosttyApp) private var app let onClose: (() -> Void)? + let baseConfig: ghostty_surface_config_s? var body: some View { if let app = app { - TerminalSplitRoot(app: app, onClose: onClose) + TerminalSplitRoot(app: app, onClose: onClose, baseConfig: baseConfig) } } } @@ -67,9 +68,9 @@ extension Ghostty { @Published var surface: SurfaceView /// Initialize a new leaf which creates a new terminal surface. - init(_ app: ghostty_app_t) { + init(_ app: ghostty_app_t, _ baseConfig: ghostty_surface_config_s?) { self.app = app - self.surface = SurfaceView(app) + self.surface = SurfaceView(app, baseConfig) } } @@ -87,7 +88,7 @@ extension Ghostty { // 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)) + self.bottomRight = .noSplit(.init(app, nil)) } } @@ -141,12 +142,14 @@ extension Ghostty { @State private var node: SplitNode @State private var requestClose: Bool = false let onClose: (() -> Void)? + let baseConfig: ghostty_surface_config_s? @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? - init(app: ghostty_app_t, onClose: (() ->Void)? = nil) { + init(app: ghostty_app_t, onClose: (() ->Void)? = nil, baseConfig: ghostty_surface_config_s? = nil) { self.onClose = onClose - _node = State(wrappedValue: SplitNode.noSplit(.init(app))) + self.baseConfig = baseConfig + _node = State(wrappedValue: SplitNode.noSplit(.init(app, baseConfig))) } var body: some View { diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index d4b3a9fa9..006433c72 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -78,6 +78,10 @@ extension Ghostty.Notification { /// Goto tab. Has tab index in the userinfo. static let ghosttyGotoTab = Notification.Name("com.mitchellh.ghostty.gotoTab") static let GotoTabKey = ghosttyGotoTab.rawValue + + /// New tab. Has base surface config requestesd in userinfo. + static let ghosttyNewTab = Notification.Name("com.mitchellh.ghostty.newTab") + static let NewTabKey = ghosttyNewTab.rawValue /// Toggle fullscreen of current window static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen") diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index cad0fdbd5..11f809fc9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -24,7 +24,7 @@ extension Ghostty { @StateObject private var surfaceView: SurfaceView init(_ app: ghostty_app_t, @ViewBuilder content: @escaping ((SurfaceView) -> Content)) { - _surfaceView = StateObject(wrappedValue: SurfaceView(app)) + _surfaceView = StateObject(wrappedValue: SurfaceView(app, nil)) self.content = content } @@ -137,7 +137,7 @@ extension Ghostty { // so we'll use that to tell ghostty to refresh. override var wantsUpdateLayer: Bool { return true } - init(_ app: ghostty_app_t) { + init(_ app: ghostty_app_t, _ baseConfig: ghostty_surface_config_s?) { self.markedText = NSMutableAttributedString() // Initialize with some default frame size. The important thing is that this @@ -146,10 +146,11 @@ extension Ghostty { 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) + var surface_cfg = baseConfig ?? ghostty_surface_config_new() + surface_cfg.userdata = Unmanaged.passUnretained(self).toOpaque() + surface_cfg.nsview = Unmanaged.passUnretained(self).toOpaque() + surface_cfg.scale_factor = NSScreen.main!.backingScaleFactor + guard let surface = ghostty_surface_new(app, &surface_cfg) else { self.error = AppError.surfaceCreateError return diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index e1ef1612b..777b7870e 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -59,6 +59,9 @@ pub const App = struct { /// views then this can be null. new_split: ?*const fn (SurfaceUD, input.SplitDirection) callconv(.C) void = null, + /// New tab with options. + new_tab: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, + /// Close the current surface given by this function. close_surface: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, @@ -162,17 +165,23 @@ pub const Surface = struct { userdata: ?*anyopaque = null, /// The pointer to the backing NSView for the surface. - nsview: *anyopaque = undefined, + nsview: ?*anyopaque = null, /// The scale factor of the screen. scale_factor: f64 = 1, + + /// The font size to inherit. If 0, default font size will be used. + font_size: u16 = 0, }; pub fn init(self: *Surface, app: *App, opts: Options) !void { + const nsview = objc.Object.fromId(opts.nsview orelse + return error.NSViewMustBeSet); + self.* = .{ .app = app, .core_surface = undefined, - .nsview = objc.Object.fromId(opts.nsview), + .nsview = nsview, .content_scale = .{ .x = @floatCast(opts.scale_factor), .y = @floatCast(opts.scale_factor), @@ -201,6 +210,13 @@ pub const Surface = struct { self, ); errdefer self.core_surface.deinit(); + + // If our options requested a specific font-size, set that. + if (opts.font_size != 0) { + var font_size = self.core_surface.font_size; + font_size.points = opts.font_size; + self.core_surface.setFontSize(font_size); + } } pub fn deinit(self: *Surface) void { @@ -593,6 +609,22 @@ pub const Surface = struct { func(self.opts.userdata, nonNativeFullscreen); } + pub fn newTab(self: *const Surface) !void { + const func = self.app.opts.new_tab orelse { + log.info("runtime embedder does not support new_tab", .{}); + return; + }; + + const font_size: u16 = font_size: { + if (!self.app.config.@"window-inherit-font-size") break :font_size 0; + break :font_size self.core_surface.font_size.points; + }; + + func(self.opts.userdata, .{ + .font_size = font_size, + }); + } + /// 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 { @@ -662,6 +694,11 @@ pub const CAPI = struct { }; } + /// Returns initial surface options. + export fn ghostty_surface_config_new() apprt.Surface.Options { + return .{}; + } + /// Create a new surface as part of an app. export fn ghostty_surface_new( app: *App, @@ -817,6 +854,7 @@ pub const CAPI = struct { const action: input.Binding.Action = switch (key) { .copy_to_clipboard => .{ .copy_to_clipboard = {} }, .paste_from_clipboard => .{ .paste_from_clipboard = {} }, + .new_tab => .{ .new_tab = {} }, }; ptr.core_surface.performBindingAction(action) catch |err| { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 6431853bd..400e9695e 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -276,6 +276,7 @@ pub const Action = union(enum) { pub const Key = enum(c_int) { copy_to_clipboard, paste_from_clipboard, + new_tab, }; /// Trigger is the associated key state that can trigger an action.