diff --git a/include/ghostty.h b/include/ghostty.h index 6de3ae0a4..77259202a 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -251,7 +251,7 @@ void ghostty_config_finalize(ghostty_config_t); ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s *, ghostty_config_t); void ghostty_app_free(ghostty_app_t); -int ghostty_app_tick(ghostty_app_t); +bool ghostty_app_tick(ghostty_app_t); void *ghostty_app_userdata(ghostty_app_t); ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 7e919e969..55d973db6 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -20,6 +20,8 @@ A5CEAFDE29B8058B00646FDA /* SplitView.Divider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */; }; A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5D495A2299BEC7E00DD1313 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; + A5FECBD729D1FC3900022361 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FECBD629D1FC3900022361 /* ContentView.swift */; }; + A5FECBD929D2010400022361 /* WindowAccessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FECBD829D2010400022361 /* WindowAccessor.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -38,6 +40,8 @@ A5CEAFDD29B8058B00646FDA /* SplitView.Divider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SplitView.Divider.swift; sourceTree = ""; }; A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = ""; }; A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = ""; }; + A5FECBD629D1FC3900022361 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + A5FECBD829D2010400022361 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -63,6 +67,8 @@ A55685DF29A03A9F004303CE /* AppError.swift */, A59444F629A2ED5200725BBA /* SettingsView.swift */, A5CEAFFE29C2410700646FDA /* Backport.swift */, + A5FECBD629D1FC3900022361 /* ContentView.swift */, + A5FECBD829D2010400022361 /* WindowAccessor.swift */, ); path = Sources; sourceTree = ""; @@ -192,11 +198,13 @@ files = ( A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */, + A5FECBD729D1FC3900022361 /* ContentView.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, A55B7BBE29B701360055DE60 /* Ghostty.SplitView.swift in Sources */, A55B7BB629B6F47F0055DE60 /* AppState.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, + A5FECBD929D2010400022361 /* WindowAccessor.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A5B30535299BEAAA0047F10C /* GhosttyApp.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, diff --git a/macos/Sources/ContentView.swift b/macos/Sources/ContentView.swift new file mode 100644 index 000000000..afe833a13 --- /dev/null +++ b/macos/Sources/ContentView.swift @@ -0,0 +1,60 @@ +import SwiftUI +import GhosttyKit + +struct ContentView: View { + let ghostty: Ghostty.AppState + + // We need access to our app delegate to know if we're quitting or not. + @EnvironmentObject private var appDelegate: AppDelegate + + // 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? + + var body: some View { + switch ghostty.readiness { + case .loading: + Text("Loading") + .onChange(of: appDelegate.confirmQuit) { value in + guard value else { return } + NSApplication.shared.reply(toApplicationShouldTerminate: true) + } + case .error: + ErrorView() + .onChange(of: appDelegate.confirmQuit) { value in + guard value else { return } + NSApplication.shared.reply(toApplicationShouldTerminate: true) + } + case .ready: + let confirmQuitting = Binding(get: { + self.appDelegate.confirmQuit && (self.window?.isKeyWindow ?? false) + }, set: { + self.appDelegate.confirmQuit = $0 + }) + + Ghostty.TerminalSplit(onClose: Self.closeWindow) + .ghosttyApp(ghostty.app!) + .background(WindowAccessor(window: $window)) + .confirmationDialog( + "Quit Ghostty?", + isPresented: confirmQuitting) { + Button("Close Ghostty", role: .destructive) { + NSApplication.shared.reply(toApplicationShouldTerminate: true) + } + .keyboardShortcut(.defaultAction) + + Button("Cancel", role: .cancel) { + NSApplication.shared.reply(toApplicationShouldTerminate: false) + } + .keyboardShortcut(.cancelAction) + } message: { + Text("All terminal sessions will be terminated.") + } + } + } + + static func closeWindow() { + guard let currentWindow = NSApp.keyWindow else { return } + currentWindow.close() + } +} diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 4463cce5c..4a76e9a9d 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -106,7 +106,13 @@ extension Ghostty { func appTick() { guard let app = self.app else { return } - ghostty_app_tick(app) + + // Tick our app, which lets us know if we want to quit + let exit = ghostty_app_tick(app) + if (!exit) { return } + + // We want to quit, start that process + NSApplication.shared.terminate(nil) } /// Request that the given surface is closed. This will trigger the full normal surface close event diff --git a/macos/Sources/GhosttyApp.swift b/macos/Sources/GhosttyApp.swift index 79f5c3521..e1f1406fe 100644 --- a/macos/Sources/GhosttyApp.swift +++ b/macos/Sources/GhosttyApp.swift @@ -18,15 +18,7 @@ struct GhosttyApp: App { var body: some Scene { WindowGroup { - switch ghostty.readiness { - case .loading: - Text("Loading") - case .error: - ErrorView() - case .ready: - Ghostty.TerminalSplit(onClose: Self.closeWindow) - .ghosttyApp(ghostty.app!) - } + ContentView(ghostty: ghostty) } .backport.defaultSize(width: 800, height: 600) .commands { @@ -110,7 +102,9 @@ struct GhosttyApp: App { } } -class AppDelegate: NSObject, NSApplicationDelegate { +class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { + @Published var confirmQuit: Bool = false + // See CursedMenuManager for more information. private var menuManager: CursedMenuManager? @@ -124,6 +118,20 @@ class AppDelegate: NSObject, NSApplicationDelegate { // we can't create from SwiftUI. menuManager = CursedMenuManager() } + + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + let windows = NSApplication.shared.windows + if (windows.isEmpty) { return .terminateNow } + + // This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't + // quit work with SwiftUI because windows are retained on close. So instead we check + // if there are any that are visible. I'm guessing this breaks under certain scenarios. + if (windows.allSatisfy { !$0.isVisible }) { return .terminateNow } + + // We have some visible window, and all our windows will watch the confirmQuit. + confirmQuit = true + return .terminateLater + } } /// SwiftUI as of macOS 13.x provides no way to manage the default menu items that are created diff --git a/macos/Sources/SettingsView.swift b/macos/Sources/SettingsView.swift index e419f9dfe..379233d55 100644 --- a/macos/Sources/SettingsView.swift +++ b/macos/Sources/SettingsView.swift @@ -1,6 +1,9 @@ import SwiftUI struct SettingsView: View { + // We need access to our app delegate to know if we're quitting or not. + @EnvironmentObject private var appDelegate: AppDelegate + var body: some View { HStack { Image("AppIconImage") @@ -18,6 +21,10 @@ struct SettingsView: View { } .padding() .frame(minWidth: 500, maxWidth: 500, minHeight: 156, maxHeight: 156) + .onChange(of: appDelegate.confirmQuit) { value in + guard value else { return } + NSApplication.shared.reply(toApplicationShouldTerminate: true) + } } } diff --git a/macos/Sources/WindowAccessor.swift b/macos/Sources/WindowAccessor.swift new file mode 100644 index 000000000..f12e841bd --- /dev/null +++ b/macos/Sources/WindowAccessor.swift @@ -0,0 +1,16 @@ +import SwiftUI + +/// Allows accessing the window that this view is a part of. +struct WindowAccessor: NSViewRepresentable { + @Binding var window: NSWindow? + + func makeNSView(context: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { + self.window = view.window + } + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/src/App.zig b/src/App.zig index b5635c0da..b39938392 100644 --- a/src/App.zig +++ b/src/App.zig @@ -81,8 +81,12 @@ pub fn tick(self: *App, rt_app: *apprt.App) !bool { i += 1; } - // Drain our mailbox only if we're not quitting. - if (!self.quit) try self.drainMailbox(rt_app); + // Drain our mailbox + try self.drainMailbox(rt_app); + + // No matter what, we reset the quit flag after a tick. If the apprt + // doesn't want to quit, then we can't force it to. + defer self.quit = false; // We quit if our quit flag is on or if we have closed all surfaces. return self.quit or self.surfaces.items.len == 0; @@ -175,11 +179,6 @@ fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void { fn setQuit(self: *App) !void { if (self.quit) return; self.quit = true; - - // Mark that all our surfaces should close - for (self.surfaces.items) |surface| { - surface.setShouldClose(); - } } /// Handle a window message diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 8fa2d0354..c492f0037 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -390,9 +390,10 @@ pub const CAPI = struct { /// Tick the event loop. This should be called whenever the "wakeup" /// callback is invoked for the runtime. - export fn ghostty_app_tick(v: *App) void { - _ = v.core_app.tick(v) catch |err| { + export fn ghostty_app_tick(v: *App) bool { + return v.core_app.tick(v) catch |err| err: { log.err("error app tick err={}", .{err}); + break :err false; }; } diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index a3e43b153..d8da82640 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -93,7 +93,13 @@ pub const App = struct { // Tick the terminal app const should_quit = try self.app.tick(self); - if (should_quit) return; + if (should_quit) { + for (self.app.surfaces.items) |surface| { + surface.close(false); + } + + return; + } } } diff --git a/src/apprt/gtk.zig b/src/apprt/gtk.zig index f7d244f7d..ccb4fc3f1 100644 --- a/src/apprt/gtk.zig +++ b/src/apprt/gtk.zig @@ -46,6 +46,9 @@ pub const App = struct { cursor_default: *c.GdkCursor, cursor_ibeam: *c.GdkCursor, + /// This is set to false when the main loop should exit. + running: bool = true, + pub fn init(core_app: *CoreApp, opts: Options) !App { _ = opts; @@ -161,12 +164,12 @@ pub const App = struct { /// Run the event loop. This doesn't return until the app exits. pub fn run(self: *App) !void { - while (true) { + while (self.running) { _ = c.g_main_context_iteration(self.ctx, 1); // Tick the terminal app const should_quit = try self.core_app.tick(self); - if (should_quit) return; + if (should_quit) self.quit(); } } @@ -192,6 +195,72 @@ pub const App = struct { try window.init(self); } + fn quit(self: *App) void { + // If we have no toplevel windows, then we're done. + const list = c.gtk_window_list_toplevels(); + if (list == null) { + self.running = false; + return; + } + c.g_list_free(list); + + // If we have windows, then we want to confirm that we want to exit. + const alert = c.gtk_message_dialog_new( + null, + c.GTK_DIALOG_MODAL, + c.GTK_MESSAGE_QUESTION, + c.GTK_BUTTONS_YES_NO, + "Quit Ghostty?", + ); + c.gtk_message_dialog_format_secondary_text( + @ptrCast(*c.GtkMessageDialog, alert), + "All active terminal sessions will be terminated.", + ); + + // We want the "yes" to appear destructive. + const yes_widget = c.gtk_dialog_get_widget_for_response( + @ptrCast(*c.GtkDialog, alert), + c.GTK_RESPONSE_YES, + ); + c.gtk_widget_add_css_class(yes_widget, "destructive-action"); + + // We want the "no" to be the default action + c.gtk_dialog_set_default_response( + @ptrCast(*c.GtkDialog, alert), + c.GTK_RESPONSE_NO, + ); + + _ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kQuitConfirmation), self, null, G_CONNECT_DEFAULT); + + c.gtk_widget_show(alert); + } + + fn gtkQuitConfirmation( + alert: *c.GtkMessageDialog, + response: c.gint, + ud: ?*anyopaque, + ) callconv(.C) void { + _ = ud; + + // Close the alert window + c.gtk_window_destroy(@ptrCast(*c.GtkWindow, alert)); + + // If we didn't confirm then we're done + if (response != c.GTK_RESPONSE_YES) return; + + // Force close all open windows + const list = c.gtk_window_list_toplevels(); + defer c.g_list_free(list); + c.g_list_foreach(list, struct { + fn callback(data: c.gpointer, _: c.gpointer) callconv(.C) void { + const ptr = data orelse return; + const widget = @ptrCast(*c.GtkWidget, @alignCast(@alignOf(c.GtkWidget), ptr)); + const window = @ptrCast(*c.GtkWindow, widget); + c.gtk_window_destroy(window); + } + }.callback, null); + } + fn activate(app: *c.GtkApplication, ud: ?*anyopaque) callconv(.C) void { _ = app; _ = ud; @@ -207,6 +276,7 @@ pub const App = struct { /// The state for a single, real GTK window. const Window = struct { const TAB_CLOSE_PAGE = "tab_close_page"; + const TAB_CLOSE_SURFACE = "tab_close_surface"; app: *App, @@ -232,6 +302,7 @@ const Window = struct { c.gtk_window_set_title(gtk_window, "Ghostty"); c.gtk_window_set_default_size(gtk_window, 200, 200); c.gtk_widget_show(window); + _ = c.g_signal_connect_data(window, "close-request", c.G_CALLBACK(>kCloseRequest), self, null, G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(>kDestroy), self, null, G_CONNECT_DEFAULT); // Create a notebook to hold our tabs. @@ -306,6 +377,7 @@ const Window = struct { // Set the userdata of the close button so it points to this page. const page = c.gtk_notebook_get_page(self.notebook, gl_area) orelse return error.GtkNotebookPageNotFound; + c.g_object_set_data(@ptrCast(*c.GObject, label_close), TAB_CLOSE_SURFACE, surface); c.g_object_set_data(@ptrCast(*c.GObject, label_close), TAB_CLOSE_PAGE, page); c.g_object_set_data(@ptrCast(*c.GObject, gl_area), TAB_CLOSE_PAGE, page); @@ -335,6 +407,9 @@ const Window = struct { else => {}, } + + // If we have remaining tabs, we need to make sure we grab focus. + if (remaining > 0) self.focusCurrentTab(); } /// Close the surface. This surface must be definitely part of this window. @@ -400,8 +475,62 @@ const Window = struct { } fn gtkTabCloseClick(btn: *c.GtkButton, ud: ?*anyopaque) callconv(.C) void { + _ = ud; + const surface = @ptrCast(*Surface, @alignCast( + @alignOf(Surface), + c.g_object_get_data(@ptrCast(*c.GObject, btn), TAB_CLOSE_SURFACE) orelse return, + )); + + surface.core_surface.close(); + } + + fn gtkCloseRequest(v: *c.GtkWindow, ud: ?*anyopaque) callconv(.C) bool { + _ = v; + log.debug("window close request", .{}); const self = userdataSelf(ud.?); - self.closeTab(getNotebookPage(@ptrCast(*c.GObject, btn)) orelse return); + + // Setup our basic message + const alert = c.gtk_message_dialog_new( + self.window, + c.GTK_DIALOG_MODAL, + c.GTK_MESSAGE_QUESTION, + c.GTK_BUTTONS_YES_NO, + "Close this window?", + ); + c.gtk_message_dialog_format_secondary_text( + @ptrCast(*c.GtkMessageDialog, alert), + "All terminal sessions in this window will be terminated.", + ); + + // We want the "yes" to appear destructive. + const yes_widget = c.gtk_dialog_get_widget_for_response( + @ptrCast(*c.GtkDialog, alert), + c.GTK_RESPONSE_YES, + ); + c.gtk_widget_add_css_class(yes_widget, "destructive-action"); + + // We want the "no" to be the default action + c.gtk_dialog_set_default_response( + @ptrCast(*c.GtkDialog, alert), + c.GTK_RESPONSE_NO, + ); + + _ = c.g_signal_connect_data(alert, "response", c.G_CALLBACK(>kCloseConfirmation), self, null, G_CONNECT_DEFAULT); + + c.gtk_widget_show(alert); + return true; + } + + fn gtkCloseConfirmation( + alert: *c.GtkMessageDialog, + response: c.gint, + ud: ?*anyopaque, + ) callconv(.C) void { + c.gtk_window_destroy(@ptrCast(*c.GtkWindow, alert)); + if (response == c.GTK_RESPONSE_YES) { + const self = userdataSelf(ud.?); + c.gtk_window_destroy(self.window); + } } /// "destroy" signal for the window diff --git a/src/config.zig b/src/config.zig index 527ac54cb..c7b55060c 100644 --- a/src/config.zig +++ b/src/config.zig @@ -346,6 +346,11 @@ pub const Config = struct { .{ .key = .w, .mods = .{ .ctrl = true, .shift = true } }, .{ .close_surface = {} }, ); + try result.keybind.set.put( + alloc, + .{ .key = .q, .mods = .{ .ctrl = true, .shift = true } }, + .{ .quit = {} }, + ); try result.keybind.set.put( alloc, .{ .key = .f4, .mods = .{ .alt = true } },