Add last window closed setting (#25185)

Mikayla Maki and Richard created

Release Notes:

- Added an `on_last_window_closed` setting, that allows users to quit
the app when the last window is closed

---------

Co-authored-by: Richard <richard@zed.dev>

Change summary

assets/settings/default.json                 |  7 +
crates/cli/src/main.rs                       |  2 
crates/gpui/Cargo.toml                       | 18 ++++
crates/gpui/examples/on_window_close_quit.rs | 82 ++++++++++++++++++++++
crates/gpui/src/app.rs                       | 16 ++++
crates/workspace/src/workspace.rs            |  1 
crates/workspace/src/workspace_settings.rs   | 34 +++++++-
crates/zed/src/zed.rs                        | 19 +++++
8 files changed, 171 insertions(+), 8 deletions(-)

Detailed changes

assets/settings/default.json πŸ”—

@@ -126,6 +126,13 @@
   //  3. Never close the window
   //         "when_closing_with_no_tabs": "keep_window_open",
   "when_closing_with_no_tabs": "platform_default",
+  // What to do when the last window is closed.
+  // May take 2 values:
+  //  1. Use the current platform's convention
+  //         "on_last_window_closed": "platform_default"
+  //  2. Always quit the application
+  //         "on_last_window_closed": "quit_app",
+  "on_last_window_closed": "platform_default",
   // Whether to use the system provided dialogs for Open and Save As.
   // When set to false, Zed will use the built-in keyboard-first pickers.
   "use_system_path_prompts": true,

crates/cli/src/main.rs πŸ”—

@@ -121,7 +121,7 @@ fn main() -> Result<()> {
     // Intercept version designators
     #[cfg(target_os = "macos")]
     if let Some(channel) = std::env::args().nth(1).filter(|arg| arg.starts_with("--")) {
-        //Β When the first argument is a name of a release channel, we're gonna spawn off a cli of that version, with trailing args passed along.
+        //When the first argument is a name of a release channel, we're gonna spawn off a cli of that version, with trailing args passed along.
         use std::str::FromStr as _;
 
         if let Ok(channel) = release_channel::ReleaseChannel::from_str(&channel[2..]) {

crates/gpui/Cargo.toml πŸ”—

@@ -22,7 +22,14 @@ test-support = [
     "x11",
 ]
 runtime_shaders = []
-macos-blade = ["blade-graphics", "blade-macros", "blade-util", "bytemuck", "objc2", "objc2-metal"]
+macos-blade = [
+    "blade-graphics",
+    "blade-macros",
+    "blade-util",
+    "bytemuck",
+    "objc2",
+    "objc2-metal",
+]
 wayland = [
     "blade-graphics",
     "blade-macros",
@@ -133,7 +140,10 @@ pathfinder_geometry = "0.5"
 [target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
 # Always used
 flume = "0.11"
-oo7 = { version = "0.4.0", default-features = false, features = ["async-std", "native_crypto"] }
+oo7 = { version = "0.4.0", default-features = false, features = [
+    "async-std",
+    "native_crypto",
+] }
 
 # Used in both windowing options
 ashpd = { workspace = true, optional = true }
@@ -265,3 +275,7 @@ path = "examples/uniform_list.rs"
 [[example]]
 name = "window_shadow"
 path = "examples/window_shadow.rs"
+
+[[example]]
+name = "on_window_close_quit"
+path = "examples/on_window_close_quit.rs"

crates/gpui/examples/on_window_close_quit.rs πŸ”—

@@ -0,0 +1,82 @@
+use gpui::{
+    actions, div, prelude::*, px, rgb, size, App, Application, Bounds, Context, FocusHandle,
+    KeyBinding, Window, WindowBounds, WindowOptions,
+};
+
+actions!(example, [CloseWindow]);
+
+struct ExampleWindow {
+    focus_handle: FocusHandle,
+}
+
+impl Render for ExampleWindow {
+    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .on_action(|_: &CloseWindow, window, _| {
+                window.remove_window();
+            })
+            .track_focus(&self.focus_handle)
+            .flex()
+            .flex_col()
+            .gap_3()
+            .bg(rgb(0x505050))
+            .size(px(500.0))
+            .justify_center()
+            .items_center()
+            .shadow_lg()
+            .border_1()
+            .border_color(rgb(0x0000ff))
+            .text_xl()
+            .text_color(rgb(0xffffff))
+            .child(
+                "Closing this window with cmd-w or the traffic lights should quit the application!",
+            )
+    }
+}
+
+fn main() {
+    Application::new().run(|cx: &mut App| {
+        let mut bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx);
+
+        cx.bind_keys([KeyBinding::new("cmd-w", CloseWindow, None)]);
+        cx.on_window_closed(|cx| {
+            if cx.windows().is_empty() {
+                cx.quit();
+            }
+        })
+        .detach();
+
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                ..Default::default()
+            },
+            |window, cx| {
+                cx.activate(false);
+                cx.new(|cx| {
+                    let focus_handle = cx.focus_handle();
+                    focus_handle.focus(window);
+                    ExampleWindow { focus_handle }
+                })
+            },
+        )
+        .unwrap();
+
+        bounds.origin.x += bounds.size.width;
+
+        cx.open_window(
+            WindowOptions {
+                window_bounds: Some(WindowBounds::Windowed(bounds)),
+                ..Default::default()
+            },
+            |window, cx| {
+                cx.new(|cx| {
+                    let focus_handle = cx.focus_handle();
+                    focus_handle.focus(window);
+                    ExampleWindow { focus_handle }
+                })
+            },
+        )
+        .unwrap();
+    });
+}

crates/gpui/src/app.rs πŸ”—

@@ -218,6 +218,7 @@ type Listener = Box<dyn FnMut(&dyn Any, &mut App) -> bool + 'static>;
 pub(crate) type KeystrokeObserver =
     Box<dyn FnMut(&KeystrokeEvent, &mut Window, &mut App) -> bool + 'static>;
 type QuitHandler = Box<dyn FnOnce(&mut App) -> LocalBoxFuture<'static, ()> + 'static>;
+type WindowClosedHandler = Box<dyn FnMut(&mut App)>;
 type ReleaseListener = Box<dyn FnOnce(&mut dyn Any, &mut App) + 'static>;
 type NewEntityListener = Box<dyn FnMut(AnyEntity, &mut Option<&mut Window>, &mut App) + 'static>;
 
@@ -260,6 +261,7 @@ pub struct App {
     pub(crate) release_listeners: SubscriberSet<EntityId, ReleaseListener>,
     pub(crate) global_observers: SubscriberSet<TypeId, Handler>,
     pub(crate) quit_observers: SubscriberSet<(), QuitHandler>,
+    pub(crate) window_closed_observers: SubscriberSet<(), WindowClosedHandler>,
     pub(crate) layout_id_buffer: Vec<LayoutId>, // We recycle this memory across layout requests.
     pub(crate) propagate_event: bool,
     pub(crate) prompt_builder: Option<PromptBuilder>,
@@ -325,6 +327,7 @@ impl App {
                 keyboard_layout_observers: SubscriberSet::new(),
                 global_observers: SubscriberSet::new(),
                 quit_observers: SubscriberSet::new(),
+                window_closed_observers: SubscriberSet::new(),
                 layout_id_buffer: Default::default(),
                 propagate_event: true,
                 prompt_builder: Some(PromptBuilder::Default),
@@ -1008,6 +1011,11 @@ impl App {
             if window.removed {
                 cx.window_handles.remove(&id);
                 cx.windows.remove(id);
+
+                cx.window_closed_observers.clone().retain(&(), |callback| {
+                    callback(cx);
+                    true
+                });
             } else {
                 cx.windows
                     .get_mut(id)
@@ -1367,6 +1375,14 @@ impl App {
         subscription
     }
 
+    /// Register a callback to be invoked when a window is closed
+    /// The window is no longer accessible at the point this callback is invoked.
+    pub fn on_window_closed(&self, mut on_closed: impl FnMut(&mut App) + 'static) -> Subscription {
+        let (subscription, activate) = self.window_closed_observers.insert((), Box::new(on_closed));
+        activate();
+        subscription
+    }
+
     pub(crate) fn clear_pending_keystrokes(&mut self) {
         for window in self.windows() {
             window

crates/workspace/src/workspace.rs πŸ”—

@@ -1804,6 +1804,7 @@ impl Workspace {
     }
 
     pub fn close_window(&mut self, _: &CloseWindow, window: &mut Window, cx: &mut Context<Self>) {
+        println!("workspace::close_window");
         let prepare = self.prepare_to_close(CloseIntent::CloseWindow, window, cx);
         cx.spawn_in(window, |_, mut cx| async move {
             if prepare.await? {

crates/workspace/src/workspace_settings.rs πŸ”—

@@ -18,11 +18,31 @@ pub struct WorkspaceSettings {
     pub autosave: AutosaveSetting,
     pub restore_on_startup: RestoreOnStartupBehavior,
     pub drop_target_size: f32,
-    pub when_closing_with_no_tabs: CloseWindowWhenNoItems,
     pub use_system_path_prompts: bool,
     pub command_aliases: HashMap<String, String>,
     pub show_user_picture: bool,
     pub max_tabs: Option<NonZeroUsize>,
+    pub when_closing_with_no_tabs: CloseWindowWhenNoItems,
+    pub on_last_window_closed: OnLastWindowClosed,
+}
+
+#[derive(Copy, Clone, Default, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum OnLastWindowClosed {
+    /// Match platform conventions by default, so don't quit on macOS, and quit on other platforms
+    #[default]
+    PlatformDefault,
+    /// Quit the application the last window is closed
+    QuitApp,
+}
+
+impl OnLastWindowClosed {
+    pub fn is_quit_app(&self) -> bool {
+        match self {
+            OnLastWindowClosed::PlatformDefault => false,
+            OnLastWindowClosed::QuitApp => true,
+        }
+    }
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)]
@@ -136,11 +156,15 @@ pub struct WorkspaceSettingsContent {
     ///
     /// Default: true
     pub show_user_picture: Option<bool>,
-    // Maximum open tabs in a pane. Will not close an unsaved
-    // tab. Set to `None` for unlimited tabs.
-    //
-    // Default: none
+    /// Maximum open tabs in a pane. Will not close an unsaved
+    /// tab. Set to `None` for unlimited tabs.
+    ///
+    /// Default: none
     pub max_tabs: Option<NonZeroUsize>,
+    /// What to do when the last window is closed
+    ///
+    /// Default: auto (nothing on macOS, "app quit" otherwise)
+    pub on_last_window_closed: Option<OnLastWindowClosed>,
 }
 
 #[derive(Deserialize)]

crates/zed/src/zed.rs πŸ”—

@@ -104,6 +104,19 @@ pub fn init(cx: &mut App) {
     }
 }
 
+fn bind_on_window_closed(cx: &mut App) -> Option<gpui::Subscription> {
+    WorkspaceSettings::get_global(cx)
+        .on_last_window_closed
+        .is_quit_app()
+        .then(|| {
+            cx.on_window_closed(|cx| {
+                if cx.windows().is_empty() {
+                    cx.quit();
+                }
+            })
+        })
+}
+
 pub fn build_window_options(display_uuid: Option<Uuid>, cx: &mut App) -> WindowOptions {
     let display = display_uuid.and_then(|uuid| {
         cx.displays()
@@ -144,6 +157,12 @@ pub fn initialize_workspace(
     prompt_builder: Arc<PromptBuilder>,
     cx: &mut App,
 ) {
+    let mut _on_close_subscription = bind_on_window_closed(cx);
+    cx.observe_global::<SettingsStore>(move |cx| {
+        _on_close_subscription = bind_on_window_closed(cx);
+    })
+    .detach();
+
     cx.observe_new(move |workspace: &mut Workspace, window, cx| {
         let Some(window) = window else {
             return;