mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 16:56:09 +03:00
Merge pull request #137 from mitchellh/app-quit
Confirmation Dialog on App Quit
This commit is contained in:
@ -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*);
|
||||
|
@ -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 */,
|
||||
|
60
macos/Sources/ContentView.swift
Normal file
60
macos/Sources/ContentView.swift
Normal 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()
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
16
macos/Sources/WindowAccessor.swift
Normal file
16
macos/Sources/WindowAccessor.swift
Normal 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) {}
|
||||
}
|
13
src/App.zig
13
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
|
||||
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
|
@ -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 } },
|
||||
|
Reference in New Issue
Block a user