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 {}; + } };