Merge pull request #137 from mitchellh/app-quit

Confirmation Dialog on App Quit
This commit is contained in:
Mitchell Hashimoto
2023-03-27 10:58:14 -07:00
committed by GitHub
12 changed files with 270 additions and 25 deletions

View File

@ -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*);

View File

@ -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 = "<group>"; };
A5CEAFFE29C2410700646FDA /* Backport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Backport.swift; sourceTree = "<group>"; };
A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = GhosttyKit.xcframework; sourceTree = "<group>"; };
A5FECBD629D1FC3900022361 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A5FECBD829D2010400022361 /* WindowAccessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowAccessor.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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 */,

View File

@ -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<Bool>(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()
}
}

View File

@ -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

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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) {}
}

View File

@ -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

View File

@ -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;
};
}

View File

@ -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;
}
}
}

View File

@ -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(&gtkQuitConfirmation), 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(&gtkCloseRequest), self, null, G_CONNECT_DEFAULT);
_ = c.g_signal_connect_data(window, "destroy", c.G_CALLBACK(&gtkDestroy), 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(&gtkCloseConfirmation), 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

View File

@ -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 } },