diff --git a/include/ghostty.h b/include/ghostty.h index af3aa1fa7..dc2f304a0 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; + uint8_t font_size; } ghostty_surface_config_s; typedef void (*ghostty_runtime_wakeup_cb)(void *); @@ -254,6 +256,7 @@ 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 void (*ghostty_runtime_toggle_fullscreen_cb)(void *, bool); +typedef void (*ghostty_runtime_new_tab_cb)(void *, uint8_t); typedef struct { void *userdata; @@ -268,6 +271,7 @@ typedef struct { ghostty_runtime_focus_split_cb focus_split_cb; ghostty_runtime_goto_tab_cb goto_tab_cb; ghostty_runtime_toggle_fullscreen_cb toggle_fullscreen_cb; + ghostty_runtime_new_tab_cb new_tab_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 066401056..efe494906 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -1,6 +1,7 @@ import AppKit import OSLog import GhosttyKit +import SwiftUI @NSApplicationMain class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { @@ -24,6 +25,22 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { super.init() windowManager = PrimaryWindowManager(ghostty: self.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) } func applicationDidFinishLaunching(_ notification: Notification) { @@ -81,13 +98,30 @@ class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { } @IBAction func newTab(_ sender: Any?) { - if let existingWindow = windowManager.mainWindow { - windowManager.addNewTab(to: existingWindow) + if windowManager.mainWindow != nil { + guard let surface = focusedSurface() else { return } + ghostty.newTab(surface: surface) } else { windowManager.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 fontSizeAny = notification.userInfo?[Ghostty.Notification.NewTabKey] + let fontSize = fontSizeAny as? UInt8 + + if fontSize != nil { + // Add the new tab to the window with the given font size. + windowManager.addNewTab(to: window, withFontSize: fontSize) + } else { + // No font size specified, just add new tab. + windowManager.addNewTab(to: window) + } + } + @IBAction func closeWindow(_ sender: Any) { guard let currentWindow = NSApp.keyWindow else { return } currentWindow.close() diff --git a/macos/Sources/Features/Primary Window/PrimaryView.swift b/macos/Sources/Features/Primary Window/PrimaryView.swift index 221c50078..40b0f464c 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 + // TODO: Document this + let fontSize: UInt8? + // 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, fontSize: self.fontSize) .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..1762f29f7 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, fontSize: UInt8? = 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, + fontSize: fontSize + )) // 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 180fee960..6d860e641 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowController.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowController.swift @@ -14,6 +14,7 @@ class PrimaryWindowController: NSWindowController { override func newWindowForTab(_ sender: Any?) { guard let window = self.window else { preconditionFailure("Expected window to be loaded") } guard let manager = self.windowManager else { return } + // TODO: We need to call to Zig code here so we can get the surface manager.addNewTab(to: window) } } diff --git a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift index 16b241070..14f93f4e9 100644 --- a/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift +++ b/macos/Sources/Features/Primary Window/PrimaryWindowManager.swift @@ -61,16 +61,16 @@ class PrimaryWindowManager { newWindow.makeKeyAndOrderFront(nil) } - func addNewTab(to window: NSWindow) { - guard let controller = createWindowController() else { return } + func addNewTab(to window: NSWindow, withFontSize fontSize: UInt8? = nil) { + guard let controller = createWindowController(withFontSize: fontSize) 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(withFontSize fontSize: UInt8? = 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, fontSize: fontSize) 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..76f422225 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -64,7 +64,8 @@ extension Ghostty { 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) }, - toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, useNonNativeFullscreen: nonNativeFullscreen) } + toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, useNonNativeFullscreen: nonNativeFullscreen) }, + new_tab_cb: { userdata, fontSize in AppState.newTab(userdata, fontSize: fontSize) } ) // Create the ghostty app. @@ -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,17 @@ extension Ghostty { ) } + static func newTab(_ userdata: UnsafeMutableRawPointer?, fontSize: UInt8) { + guard let surface = self.surfaceUserdata(from: userdata) else { return } + NotificationCenter.default.post( + name: Notification.ghosttyNewTab, + object: surface, + userInfo: [ + Notification.NewTabKey: fontSize, + ] + ) + } + /// 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..9142dd037 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 fontSize: UInt8? var body: some View { if let app = app { - TerminalSplitRoot(app: app, onClose: onClose) + TerminalSplitRoot(app: app, onClose: onClose, fontSize: fontSize) } } } @@ -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, _ fontSize: UInt8?) { self.app = app - self.surface = SurfaceView(app) + self.surface = SurfaceView(app, fontSize) } } @@ -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 fontSize: UInt8? @FocusedValue(\.ghosttySurfaceTitle) private var surfaceTitle: String? - init(app: ghostty_app_t, onClose: (() ->Void)? = nil) { + init(app: ghostty_app_t, onClose: (() ->Void)? = nil, fontSize: UInt8? = nil) { self.onClose = onClose - _node = State(wrappedValue: SplitNode.noSplit(.init(app))) + self.fontSize = fontSize + _node = State(wrappedValue: SplitNode.noSplit(.init(app, fontSize))) } var body: some View { diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index d4b3a9fa9..25a10b8ce 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 font size of currently focused surface in the 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..1960edd60 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, _ fontSize: UInt8?) { self.markedText = NSMutableAttributedString() // Initialize with some default frame size. The important thing is that this @@ -149,7 +149,8 @@ extension Ghostty { var surface_cfg = ghostty_surface_config_s( userdata: Unmanaged.passUnretained(self).toOpaque(), nsview: Unmanaged.passUnretained(self).toOpaque(), - scale_factor: NSScreen.main!.backingScaleFactor) + scale_factor: NSScreen.main!.backingScaleFactor, + font_size: fontSize ?? 0) 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 68a3c805a..5315614d9 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -70,6 +70,10 @@ pub const App = struct { /// Toggle fullscreen for current window. toggle_fullscreen: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, + + /// New tab with desired font size in points + /// TODO: u8 should be something that's nullable + new_tab: ?*const fn (SurfaceUD, u8) callconv(.C) void = null, }; core_app: *CoreApp, @@ -166,6 +170,9 @@ pub const Surface = struct { /// 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: u8 = 0, }; pub fn init(self: *Surface, app: *App, opts: Options) !void { @@ -191,6 +198,13 @@ pub const Surface = struct { var config = try apprt.surface.newConfig(app.core_app, app.config); defer config.deinit(); + // Overwrite the config for this new surface if we need to set a font + // size based on parent. + // TODO: Is this super hacky? + if (opts.font_size != 0) { + config.@"font-size" = opts.font_size; + } + // Initialize our surface right away. We're given a view that is // ready to use. try self.core_surface.init( @@ -580,6 +594,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; + }; + + // TODO: Do we check this here? Or do we check this in embedder? + // + const font_size: u8 = font_size: { + if (!self.app.config.@"window-inherit-font-size") break :font_size 0; + break :font_size @intCast(self.core_surface.font_size.points); + }; + + func(self.opts.userdata, 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 { @@ -804,6 +834,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.