diff --git a/apps/terminal/src/main.rs b/apps/terminal/src/main.rs index b3feb4a9b7..f85931def2 100644 --- a/apps/terminal/src/main.rs +++ b/apps/terminal/src/main.rs @@ -144,11 +144,11 @@ impl MatchEvent for App { }; // The person said yes to ending the running jobs: close now. if let ModuleCloseAction::Confirmed = wa.cast::() { - cx.quit(); + close_self(cx); } match wa.cast::() { // Ctrl+Shift+W on the last tab closes the window. - TermTabsAction::LastTabClosed => cx.quit(), + TermTabsAction::LastTabClosed => close_self(cx), TermTabsAction::None => {} } match wa.cast::() { @@ -166,6 +166,16 @@ impl MatchEvent for App { } } +/// End this terminal. Standalone it quits. Hosted, the window manager owns +/// the process and its tile, and the hosted runtime does not act on +/// `cx.quit()`: the terminal asks the WM to close it (`WmRequest::Close`, +/// "the app finished"), which the WM carries out without asking back. +fn close_self(cx: &mut Cx) { + if !makepad_wm_api::send(cx, &makepad_wm_api::WmRequest::Close) { + cx.quit(); + } +} + impl App { /// The selected tab's terminal. fn term(&self, cx: &mut Cx) -> WidgetRef { @@ -272,11 +282,20 @@ impl App { term.unload(cx); } } - WmEvent::CloseRequested => { - if self.ask_before_closing(cx) == CloseDecision::Allow { - cx.quit(); - } + // The WM greets a hosted app: tell it a close is answered here, + // so it waits for the answer instead of killing running jobs. + WmEvent::Hosted { .. } => { + makepad_wm_api::asks_before_close(cx); } + // Allow: close. Veto: the bar asks the person; the WM hears + // "asking" so it neither kills nor gives up on us, and every + // repeated close is answered again (a live terminal re-asks). + WmEvent::CloseRequested => match self.ask_before_closing(cx) { + CloseDecision::Allow => close_self(cx), + CloseDecision::Veto => { + makepad_wm_api::close_refused(cx); + } + }, // Adopted into a real tile: now it is a running Terminal. WmEvent::Adopted => self.open_ai_port(cx), _ => {} diff --git a/apps/wm/src/lib.rs b/apps/wm/src/lib.rs index 549b48f377..39d8fc2ea8 100644 --- a/apps/wm/src/lib.rs +++ b/apps/wm/src/lib.rs @@ -1334,6 +1334,9 @@ impl App { log!("wm: notify from client {}: {} — {}", client, title, body); } WmRequest::Close => self.request_close(cx, client), + // This WM still closes with CloseRequested + Kill: it neither + // waits for an answer nor needs the app's refusal. + WmRequest::AsksBeforeClose | WmRequest::CloseRefused => {} WmRequest::SetFloating { floating } => { let area = self.desk_area(cx); let gap = self.state_mut().gap; diff --git a/libs/wm_api/src/lib.rs b/libs/wm_api/src/lib.rs index c712e6dc5a..913f89711d 100644 --- a/libs/wm_api/src/lib.rs +++ b/libs/wm_api/src/lib.rs @@ -46,6 +46,19 @@ pub enum WmRequest { /// Ask the WM to float / tile / fullscreen this window. SetFloating { floating: bool }, SetFullscreen { fullscreen: bool }, + /// This app answers [`WmEvent::CloseRequested`] itself: it agrees with + /// [`WmRequest::Close`], or refuses with [`WmRequest::CloseRefused`] + /// while it asks the person (the terminal, with jobs running). Sent in + /// reply to [`WmEvent::Hosted`]. A WM that heard it sends + /// `CloseRequested` alone and waits for the answer instead of following + /// it with a kill; a WM that did not keeps closing the app as before. + AsksBeforeClose, + /// The answer to a [`WmEvent::CloseRequested`]: not now — the app is + /// showing the person a question in its own window. On a yes it sends + /// [`WmRequest::Close`] (a hosted app cannot end its own process with + /// `cx.quit()`); a no leaves it running. Sent on every refusal, so a WM + /// can tell an app that is asking from one that stopped answering. + CloseRefused, } /// What the window manager tells an app. @@ -143,6 +156,18 @@ pub fn send(cx: &Cx, req: &WmRequest) -> bool { true } +/// Tell the WM this app answers `CloseRequested` itself +/// ([`WmRequest::AsksBeforeClose`]); call it on [`WmEvent::Hosted`]. +pub fn asks_before_close(cx: &Cx) -> bool { + send(cx, &WmRequest::AsksBeforeClose) +} + +/// Answer a `CloseRequested` with "not now": the app is asking the person +/// ([`WmRequest::CloseRefused`]). No-op standalone. +pub fn close_refused(cx: &Cx) -> bool { + send(cx, &WmRequest::CloseRefused) +} + /// Quick Look `path`. Hosted: the WM floats the associated viewer over the /// desk. Standalone: spawn the viewer's binary with `--preview` next to /// this executable (best effort; false when nothing could be started). @@ -292,6 +317,17 @@ mod tests { assert_eq!(WmEvent::parse(&WmEvent::CloseRequested.to_json()), Some(WmEvent::CloseRequested)); } + #[test] + fn close_answers_round_trip() { + // The wire form is part of the contract: a WM pinned before these + // variants recognises them by this exact text. + assert_eq!(WmRequest::AsksBeforeClose.to_json(), r#"{"wm":{"AsksBeforeClose":[]}}"#); + assert_eq!(WmRequest::CloseRefused.to_json(), r#"{"wm":{"CloseRefused":[]}}"#); + for req in [WmRequest::AsksBeforeClose, WmRequest::CloseRefused] { + assert_eq!(WmRequest::parse(&req.to_json()), Some(req)); + } + } + #[test] fn associations() { assert_eq!(viewer_for(Path::new("a/B.PNG")), "image");