From 6736a09abe6a58ca53535d1307363a91e0de42fb Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:25:52 +0000 Subject: [PATCH] gpui: Add is_visible and on_visibility_changed API on Window (#64107) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `WindowVisibility` API to GPUI: `Window::visibility()` / `Window::is_visible()`, `Window::observe_window_visibility`, and `Context::observe_window_visibility`, backed by one platform signal per backend — `NSWindow.occlusionState` on macOS, `WS_VISIBLE` plus the minimized state on Windows, the xdg-shell v6 `suspended` toplevel state on Wayland, mapped state plus `VisibilityNotify` on X11, and the Page Visibility API on web. Only transitions are reported, the initial value is read from the platform when the window is created, and the two `PlatformWindow` methods have no default so a backend cannot silently opt out. The per-platform limits (Windows can't see a fully covered window, pre-v6 Wayland compositors never report `Hidden`) are documented on the type. It also adds `Platform::on_system_sleep` / `App::on_system_sleep`, mirroring the existing wake hook, on macOS (`NSWorkspaceWillSleepNotification`), Windows (`PBT_APMSUSPEND`), and Linux (logind `PrepareForSleep(true)`, which the listener previously discarded). Nothing in Zed subscribes to either yet; the hang-telemetry journal will use visibility to stop measuring frames that could not have been presented, and the sleep/wake pair to bracket time the process was not running. Release Notes: - N/A --- crates/gpui/src/app.rs | 33 +++++ crates/gpui/src/app/context.rs | 20 +++ crates/gpui/src/app/test_context.rs | 17 ++- crates/gpui/src/platform.rs | 47 +++++++ crates/gpui/src/platform/test/platform.rs | 2 + crates/gpui/src/platform/test/window.rs | 26 +++- crates/gpui/src/platform/visual_test.rs | 2 + crates/gpui/src/window.rs | 102 +++++++++++++- .../gpui_linux/src/linux/headless/client.rs | 8 +- .../gpui_linux/src/linux/headless/window.rs | 9 +- crates/gpui_linux/src/linux/platform.rs | 71 +++++++--- crates/gpui_linux/src/linux/wayland/client.rs | 16 ++- crates/gpui_linux/src/linux/wayland/window.rs | 68 +++++++--- crates/gpui_linux/src/linux/x11/client.rs | 52 ++++++-- crates/gpui_linux/src/linux/x11/window.rs | 28 +++- crates/gpui_macos/src/platform.rs | 126 +++++++++++++----- crates/gpui_macos/src/window.rs | 73 +++++++++- crates/gpui_web/src/platform.rs | 4 + crates/gpui_web/src/window.rs | 54 +++++--- crates/gpui_windows/src/events.rs | 37 ++++- crates/gpui_windows/src/platform.rs | 15 ++- crates/gpui_windows/src/window.rs | 28 ++++ 22 files changed, 719 insertions(+), 119 deletions(-) diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index ee09f3a14c..cb4eb66edd 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -707,6 +707,7 @@ pub struct App { pub(crate) keystroke_interceptors: SubscriberSet<(), KeystrokeObserver>, pub(crate) keyboard_layout_observers: SubscriberSet<(), Handler>, pub(crate) thermal_state_observers: SubscriberSet<(), Handler>, + pub(crate) system_sleep_observers: SubscriberSet<(), Handler>, pub(crate) system_wake_observers: SubscriberSet<(), Handler>, pub(crate) release_listeners: SubscriberSet, pub(crate) global_observers: SubscriberSet, @@ -841,6 +842,7 @@ impl App { keystroke_interceptors: SubscriberSet::new(), keyboard_layout_observers: SubscriberSet::new(), thermal_state_observers: SubscriberSet::new(), + system_sleep_observers: SubscriberSet::new(), system_wake_observers: SubscriberSet::new(), global_observers: SubscriberSet::new(), quit_observers: SubscriberSet::new(), @@ -901,6 +903,18 @@ impl App { } })); + platform.on_system_sleep(Box::new({ + let app = Rc::downgrade(&app); + move || { + if let Some(app) = app.upgrade() { + let cx = &mut app.borrow_mut(); + cx.system_sleep_observers + .clone() + .retain(&(), move |callback| (callback)(cx)); + } + } + })); + platform.on_system_wake(Box::new({ let app = Rc::downgrade(&app); move || { @@ -1371,6 +1385,25 @@ impl App { subscription } + /// Invokes a handler when the system is about to sleep. + /// + /// The platform gives the process only a short time before suspending, so + /// handlers should record state or cancel work rather than start it. + pub fn on_system_sleep(&self, mut callback: F) -> Subscription + where + F: 'static + FnMut(&mut App), + { + let (subscription, activate) = self.system_sleep_observers.insert( + (), + Box::new(move |cx| { + callback(cx); + true + }), + ); + activate(); + subscription + } + /// Invokes a handler when the system wakes from sleep. pub fn on_system_wake(&self, mut callback: F) -> Subscription where diff --git a/crates/gpui/src/app/context.rs b/crates/gpui/src/app/context.rs index 0d1ee47ce4..bc77a9236f 100644 --- a/crates/gpui/src/app/context.rs +++ b/crates/gpui/src/app/context.rs @@ -2,6 +2,7 @@ use crate::{ AnyView, AnyWindowHandle, AppContext, AsyncApp, DispatchPhase, Effect, EntityId, EventEmitter, FocusHandle, FocusOutEvent, Focusable, Global, KeystrokeObserver, Priority, Reservation, SubscriberSet, Subscription, Task, WeakEntity, WeakFocusHandle, Window, WindowHandle, + WindowVisibility, }; use anyhow::Result; use futures::FutureExt; @@ -458,6 +459,25 @@ impl<'a, T: 'static> Context<'a, T> { subscription } + /// Registers a callback to be invoked when the window's visibility changes + /// (see [`WindowVisibility`]). + pub fn observe_window_visibility( + &self, + window: &mut Window, + mut callback: impl FnMut(&mut T, WindowVisibility, &mut Window, &mut Context) + 'static, + ) -> Subscription { + let view = self.weak_entity(); + let (subscription, activate) = window.visibility_observers.insert( + (), + Box::new(move |visibility, window, cx| { + view.update(cx, |view, cx| callback(view, visibility, window, cx)) + .is_ok() + }), + ); + activate(); + subscription + } + /// Registers a callback to be invoked when the window appearance changes. pub fn observe_window_appearance( &self, diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 5722d69e42..fd4caf9cc3 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -6,7 +6,7 @@ use crate::{ Pixels, Platform, Point, Render, Result, SharedString, Size, SystemNotification, SystemNotificationResponse, Task, TestDispatcher, TestPlatform, TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, WindowHandle, WindowOptions, - app::GpuiMode, window::ElementArenaScope, + WindowVisibility, app::GpuiMode, window::ElementArenaScope, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; @@ -418,6 +418,16 @@ impl TestAppContext { self.test_window(window_handle).simulate_resize(size); } + /// Simulates a change in whether the platform is presenting the window. + pub fn simulate_window_visibility_change( + &self, + window_handle: AnyWindowHandle, + visibility: WindowVisibility, + ) { + self.test_window(window_handle) + .simulate_visibility_change(visibility); + } + /// Simulates visible viewport changes without resizing the window's layout area. pub fn simulate_window_visual_viewport_change( &self, @@ -919,6 +929,11 @@ impl VisualTestContext { self.simulate_window_resize(self.window, size) } + /// Simulates a change in whether the platform is presenting this window. + pub fn simulate_visibility_change(&self, visibility: WindowVisibility) { + self.simulate_window_visibility_change(self.window, visibility); + } + /// Simulates the window moving to a display with a different scale factor. pub fn simulate_scale_factor_change(&self, scale_factor: f32) { self.simulate_window_scale_factor_change(self.window, scale_factor) diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 935fdaf223..2c7fbb0bc6 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -78,6 +78,43 @@ pub use app_menu::*; pub use keyboard::*; pub use keystroke::*; +/// Whether the platform is presenting a window's frames. +/// +/// This is about presentation, not the window's shown/hidden state: a shown +/// window that is fully covered by other windows, minimized, on another +/// Space or virtual desktop, or on a display that is asleep is `Hidden`. A +/// window only partly covered by other windows is `Visible`. +/// +/// Each platform reports from a single source, and what that source can see +/// differs: +/// +/// * macOS: `NSWindow.occlusionState`. Covers all of the cases above. +/// * Windows: `WS_VISIBLE` and the minimized state. Windows keeps compositing +/// covered windows for thumbnails and Alt-Tab and offers no occlusion +/// notification, so a fully covered window stays `Visible`. Display sleep is +/// not reported either. +/// * Wayland: the `xdg_toplevel` `suspended` state (xdg-shell v6). Compositors +/// that don't support it never report `Hidden`. +/// * X11: mapped state plus `VisibilityNotify`. Compositing window managers +/// generally never report a window as fully obscured, so covering is only +/// detected without compositing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WindowVisibility { + /// At least part of the window is being presented; frames drawn for it + /// will be shown. + Visible, + /// No part of the window is being presented. The platform will not + /// request frames for it until it becomes visible again. + Hidden, +} + +impl WindowVisibility { + /// Whether frames drawn for the window will be shown. + pub fn is_visible(self) -> bool { + self == Self::Visible + } +} + #[cfg(any(test, feature = "test-support", feature = "bench-support"))] pub(crate) use test::*; @@ -221,6 +258,9 @@ pub trait Platform: 'static { fn on_quit(&self, callback: Box bool>); fn on_reopen(&self, callback: Box); + /// Registers the callback invoked when the system is about to sleep. + fn on_system_sleep(&self, callback: Box); + /// Registers the callback invoked when the system resumes from sleep. fn on_system_wake(&self, callback: Box); // Mobile platform methods. On mobile the OS owns the application @@ -883,6 +923,9 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { /// Requests that the operating system draw attention to this window. fn request_attention(&self) {} fn is_active(&self) -> bool; + /// The current [`WindowVisibility`]. Read once when the window is created; + /// afterwards changes arrive through [`Self::on_visibility_change`]. + fn visibility(&self) -> WindowVisibility; fn is_hovered(&self) -> bool; fn background_appearance(&self) -> WindowBackgroundAppearance; fn set_title(&mut self, title: &str); @@ -897,6 +940,10 @@ pub trait PlatformWindow: HasWindowHandle + HasDisplayHandle { fn on_request_frame(&self, callback: Box); fn on_input(&self, callback: Box DispatchEventResult>); fn on_active_status_change(&self, callback: Box); + /// Registers the callback invoked when [`Self::visibility`] changes. Only + /// transitions are reported; the callback runs on the main thread outside + /// of any window update. + fn on_visibility_change(&self, callback: Box); fn on_hover_status_change(&self, callback: Box); fn on_resize(&self, callback: Box, f32)>); fn on_moved(&self, callback: Box); diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 21422e16f9..77a644114f 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -584,6 +584,8 @@ impl Platform for TestPlatform { unimplemented!() } + fn on_system_sleep(&self, _callback: Box) {} + fn on_system_wake(&self, _callback: Box) {} fn set_app_identity(&self, identifier: &str, name: &str) { diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 9910e17c9c..ddce59adf3 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -4,7 +4,7 @@ use crate::{ PlatformHeadlessRenderer, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, RequestFrameOptions, Scene, Size, TestPlatform, TextInputConfiguration, TextInputStateChange, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowInsets, WindowParams, + WindowControlArea, WindowInsets, WindowParams, WindowVisibility, }; use collections::HashMap; use gpui_util::ResultExt as _; @@ -34,6 +34,8 @@ pub(crate) struct TestWindowState { hit_test_window_control_callback: Option Option>>, input_callback: Option DispatchEventResult>>, active_status_change_callback: Option>, + visibility: WindowVisibility, + visibility_callback: Option>, hover_status_change_callback: Option>, resize_callback: Option, f32)>>, visual_viewport: Option>, @@ -105,6 +107,8 @@ impl TestWindow { hit_test_window_control_callback: None, input_callback: None, active_status_change_callback: None, + visibility: WindowVisibility::Visible, + visibility_callback: None, hover_status_change_callback: None, resize_callback: None, visual_viewport: None, @@ -153,6 +157,18 @@ impl TestWindow { self.0.lock().frame_scheduled } + pub fn simulate_visibility_change(&self, visibility: WindowVisibility) { + let callback = { + let mut state = self.0.lock(); + state.visibility = visibility; + state.visibility_callback.take() + }; + if let Some(mut callback) = callback { + callback(visibility); + self.0.lock().visibility_callback = Some(callback); + } + } + pub fn simulate_visual_viewport_change(&self, bounds: Bounds) { let callback = { let mut state = self.0.lock(); @@ -394,6 +410,10 @@ impl PlatformWindow for TestWindow { false } + fn visibility(&self) -> WindowVisibility { + self.0.lock().visibility + } + fn is_hovered(&self) -> bool { false } @@ -476,6 +496,10 @@ impl PlatformWindow for TestWindow { self.0.lock().active_status_change_callback = Some(callback) } + fn on_visibility_change(&self, callback: Box) { + self.0.lock().visibility_callback = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.0.lock().hover_status_change_callback = Some(callback) } diff --git a/crates/gpui/src/platform/visual_test.rs b/crates/gpui/src/platform/visual_test.rs index 2db3e34db8..0454c31014 100644 --- a/crates/gpui/src/platform/visual_test.rs +++ b/crates/gpui/src/platform/visual_test.rs @@ -176,6 +176,8 @@ impl Platform for VisualTestPlatform { fn on_reopen(&self, _callback: Box) {} + fn on_system_sleep(&self, _callback: Box) {} + fn on_system_wake(&self, _callback: Box) {} fn set_menus(&self, _menus: Vec, _keymap: &Keymap) {} diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index e82d7515c3..c39e577cec 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -22,7 +22,7 @@ use crate::{ TextInputStateChange, TextRenderingMode, TextStyle, TextStyleRefinement, ThermalState, TransformationMatrix, Underline, UnderlineStyle, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControls, WindowDecorations, WindowOptions, WindowParams, WindowTextSystem, - point, prelude::*, px, rems, size, transparent_black, + WindowVisibility, point, prelude::*, px, rems, size, transparent_black, }; use crate::gestures::{GestureTuning, RecognizedTouchGesture, TouchGestureRecognizer}; @@ -1195,6 +1195,9 @@ pub struct Window { pub(crate) appearance_observers: SubscriberSet<(), AnyObserver>, pub(crate) button_layout_observers: SubscriberSet<(), AnyObserver>, active: Rc>, + visibility: WindowVisibility, + pub(crate) visibility_observers: + SubscriberSet<(), Box bool>>, hovered: Rc>, pub(crate) needs_present: Rc>, /// Tracks recent input event timestamps to determine if input is arriving at a high rate. @@ -1568,6 +1571,7 @@ impl Window { let text_system = Arc::new(WindowTextSystem::new(cx.text_system().clone())); let invalidator = WindowInvalidator::new(handle.window_id()); let active = Rc::new(Cell::new(platform_window.is_active())); + let visibility = platform_window.visibility(); let hovered = Rc::new(Cell::new(platform_window.is_hovered())); let needs_present = Rc::new(Cell::new(false)); let next_frame_callbacks: Rc>> = Default::default(); @@ -1895,6 +1899,23 @@ impl Window { .log_err(); } })); + platform_window.on_visibility_change(Box::new({ + let mut cx = cx.to_async(); + move |visibility| { + handle + .update(&mut cx, |_, window, cx| { + if window.visibility == visibility { + return; + } + window.visibility = visibility; + window + .visibility_observers + .clone() + .retain(&(), |callback| callback(visibility, window, cx)); + }) + .log_err(); + } + })); platform_window.on_hover_status_change(Box::new({ let mut cx = cx.to_async(); move |active| { @@ -2035,6 +2056,8 @@ impl Window { appearance_observers: SubscriberSet::new(), button_layout_observers: SubscriberSet::new(), active, + visibility, + visibility_observers: SubscriberSet::new(), hovered, needs_present, input_rate_tracker, @@ -2128,6 +2151,37 @@ impl Window { } } + /// Whether the platform is presenting this window's frames (see + /// [`WindowVisibility`]). + pub fn visibility(&self) -> WindowVisibility { + self.visibility + } + + /// Whether frames drawn for this window will be shown. + /// + /// This is not the window's shown/hidden state: a shown window that is + /// fully behind another window, minimized, or on a sleeping display is not + /// visible here. + pub fn is_visible(&self) -> bool { + self.visibility.is_visible() + } + + /// Registers a callback to be invoked when the window's visibility changes. + pub fn observe_window_visibility( + &self, + mut callback: impl FnMut(WindowVisibility, &mut Window, &mut App) + 'static, + ) -> Subscription { + let (subscription, activate) = self.visibility_observers.insert( + (), + Box::new(move |visibility, window, cx| { + callback(visibility, window, cx); + true + }), + ); + activate(); + subscription + } + /// Registers a callback to be invoked when the window appearance changes. pub fn observe_window_appearance( &self, @@ -7442,6 +7496,52 @@ mod tests { WindowOptions, canvas, div, point, px, size, }; + /// Visibility transitions reach observers exactly once each, with the new + /// state already stored on the window, and never wake the platform for a + /// frame: the platform requests one itself when it resumes presenting. + #[gpui::test] + fn test_window_visibility(cx: &mut TestAppContext) { + use crate::WindowVisibility; + + let window = cx.add_window(|_, _| EmptyView); + let observed = Rc::new(RefCell::new(Vec::new())); + let _subscription = window + .update(cx, { + let observed = observed.clone(); + move |_, window, _| { + assert_eq!(window.visibility(), WindowVisibility::Visible); + assert!(window.is_visible()); + window.observe_window_visibility(move |visibility, window, _| { + assert_eq!(window.visibility(), visibility); + observed.borrow_mut().push(visibility); + }) + } + }) + .unwrap(); + let test_window = cx.test_window(window.into()); + let frame_wake_count = test_window.frame_wake_count(); + + test_window.simulate_visibility_change(WindowVisibility::Hidden); + assert_eq!(*observed.borrow(), [WindowVisibility::Hidden]); + window + .update(cx, |_, window, _| assert!(!window.is_visible())) + .unwrap(); + + // Platforms may report the same state again; observers only see changes. + test_window.simulate_visibility_change(WindowVisibility::Hidden); + assert_eq!(observed.borrow().len(), 1); + + test_window.simulate_visibility_change(WindowVisibility::Visible); + assert_eq!( + *observed.borrow(), + [WindowVisibility::Hidden, WindowVisibility::Visible] + ); + window + .update(cx, |_, window, _| assert!(window.is_visible())) + .unwrap(); + assert_eq!(test_window.frame_wake_count(), frame_wake_count); + } + #[gpui::test] fn test_fully_visible_bounds_preserve_layout_viewport(cx: &mut TestAppContext) { let window = cx.add_window(|_, _| EmptyView); diff --git a/crates/gpui_linux/src/linux/headless/client.rs b/crates/gpui_linux/src/linux/headless/client.rs index 06da3cbd94..0e94a06007 100644 --- a/crates/gpui_linux/src/linux/headless/client.rs +++ b/crates/gpui_linux/src/linux/headless/client.rs @@ -25,7 +25,7 @@ impl HeadlessClient { pub(crate) fn new() -> Self { let event_loop = EventLoop::try_new().unwrap(); - let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, power_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); @@ -38,9 +38,9 @@ impl HeadlessClient { .ok(); handle - .insert_source(wake_receiver, |event, _, client: &mut HeadlessClient| { - if let calloop::channel::Event::Msg(()) = event { - client.with_common(|common| common.handle_system_wake()); + .insert_source(power_receiver, |event, _, client: &mut HeadlessClient| { + if let calloop::channel::Event::Msg(event) = event { + client.with_common(|common| common.handle_system_power_event(event)); } }) .ok(); diff --git a/crates/gpui_linux/src/linux/headless/window.rs b/crates/gpui_linux/src/linux/headless/window.rs index e41b09723d..42575c385c 100644 --- a/crates/gpui_linux/src/linux/headless/window.rs +++ b/crates/gpui_linux/src/linux/headless/window.rs @@ -19,7 +19,7 @@ use gpui::{ DisplayId, GpuSpecs, Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, Scene, Size, TileId, WindowAppearance, WindowBackgroundAppearance, WindowBounds, - WindowControlArea, WindowParams, px, + WindowControlArea, WindowParams, WindowVisibility, px, }; #[derive(Debug)] @@ -159,6 +159,11 @@ impl PlatformWindow for HeadlessWindow { false } + fn visibility(&self) -> WindowVisibility { + // There is no display server: `draw` discards every scene. + WindowVisibility::Hidden + } + fn is_hovered(&self) -> bool { false } @@ -198,6 +203,8 @@ impl PlatformWindow for HeadlessWindow { fn on_active_status_change(&self, _callback: Box) {} + fn on_visibility_change(&self, _callback: Box) {} + fn on_hover_status_change(&self, _callback: Box) {} fn on_resize(&self, _callback: Box, f32)>) {} diff --git a/crates/gpui_linux/src/linux/platform.rs b/crates/gpui_linux/src/linux/platform.rs index f2184543e7..5da8b5a27f 100644 --- a/crates/gpui_linux/src/linux/platform.rs +++ b/crates/gpui_linux/src/linux/platform.rs @@ -120,9 +120,22 @@ pub(crate) struct PlatformHandlers { pub(crate) will_open_app_menu: Option>, pub(crate) validate_app_menu_command: Option bool>>, pub(crate) keyboard_layout_change: Option>, + pub(crate) system_sleep: Option>, pub(crate) system_wake: Option>, } +/// A logind `PrepareForSleep` signal, forwarded from the D-Bus listener to +/// the client's event loop. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr( + not(all(target_os = "linux", any(feature = "wayland", feature = "x11"))), + allow(dead_code) +)] +pub(crate) enum SystemPowerEvent { + Sleep, + Wake, +} + pub(crate) struct LinuxCommon { pub(crate) background_executor: BackgroundExecutor, pub(crate) foreground_executor: ForegroundExecutor, @@ -139,8 +152,8 @@ pub(crate) struct LinuxCommon { not(all(target_os = "linux", any(feature = "wayland", feature = "x11"))), allow(dead_code) )] - wake_sender: Sender<()>, - wake_listener_started: bool, + power_sender: Sender, + power_listener_started: bool, } impl LinuxCommon { @@ -149,10 +162,10 @@ impl LinuxCommon { ) -> ( Self, PriorityQueueCalloopReceiver, - calloop::channel::Channel<()>, + calloop::channel::Channel, ) { let (main_sender, main_receiver) = PriorityQueueCalloopReceiver::new(); - let (wake_sender, wake_receiver) = calloop::channel::channel(); + let (power_sender, power_receiver) = calloop::channel::channel(); #[cfg(any(feature = "wayland", feature = "x11"))] let text_system = Arc::new(crate::linux::CosmicTextSystem::new("IBM Plex Sans")); @@ -178,40 +191,45 @@ impl LinuxCommon { app_name: None, system_notifications: crate::linux::system_notifications::SystemNotificationState::new( ), - wake_sender, - wake_listener_started: false, + power_sender, + power_listener_started: false, }; - (common, main_receiver, wake_receiver) + (common, main_receiver, power_receiver) } - pub(crate) fn start_wake_listener(&mut self) { - if !self.wake_listener_started { + pub(crate) fn start_power_listener(&mut self) { + if !self.power_listener_started { #[cfg(all(target_os = "linux", any(feature = "wayland", feature = "x11")))] smol::spawn({ - let wake_sender = self.wake_sender.clone(); + let power_sender = self.power_sender.clone(); async move { - if let Err(error) = listen_for_system_wake(wake_sender).await { - log::debug!("failed to listen for system wake events: {error:?}"); + if let Err(error) = listen_for_system_power_events(power_sender).await { + log::debug!("failed to listen for system sleep/wake events: {error:?}"); } } }) .detach(); - self.wake_listener_started = true; + self.power_listener_started = true; } } - pub(crate) fn handle_system_wake(&mut self) { - if let Some(mut callback) = self.callbacks.system_wake.take() { + pub(crate) fn handle_system_power_event(&mut self, event: SystemPowerEvent) { + let callback = match event { + SystemPowerEvent::Sleep => &mut self.callbacks.system_sleep, + SystemPowerEvent::Wake => &mut self.callbacks.system_wake, + }; + if let Some(callback) = callback.as_mut() { callback(); - self.callbacks.system_wake = Some(callback); } } } #[cfg(all(target_os = "linux", any(feature = "wayland", feature = "x11")))] -async fn listen_for_system_wake(wake_sender: Sender<()>) -> anyhow::Result<()> { +async fn listen_for_system_power_events( + power_sender: Sender, +) -> anyhow::Result<()> { use futures::StreamExt as _; let connection = ashpd::zbus::Connection::system().await?; @@ -225,10 +243,12 @@ async fn listen_for_system_wake(wake_sender: Sender<()>) -> anyhow::Result<()> { let mut sleep_events = proxy.receive_signal("PrepareForSleep").await?; while let Some(message) = sleep_events.next().await { - let sleeping = message.body().deserialize::()?; - if !sleeping { - wake_sender.send(()).ok(); - } + let event = if message.body().deserialize::()? { + SystemPowerEvent::Sleep + } else { + SystemPowerEvent::Wake + }; + power_sender.send(event).ok(); } Ok(()) @@ -594,10 +614,17 @@ impl Platform for LinuxPlatform

{ }); } + fn on_system_sleep(&self, callback: Box) { + self.inner.with_common(|common| { + common.callbacks.system_sleep = Some(callback); + common.start_power_listener(); + }); + } + fn on_system_wake(&self, callback: Box) { self.inner.with_common(|common| { common.callbacks.system_wake = Some(callback); - common.start_wake_listener(); + common.start_power_listener(); }); } diff --git a/crates/gpui_linux/src/linux/wayland/client.rs b/crates/gpui_linux/src/linux/wayland/client.rs index a5c6dc8925..0785ae9e24 100644 --- a/crates/gpui_linux/src/linux/wayland/client.rs +++ b/crates/gpui_linux/src/linux/wayland/client.rs @@ -260,7 +260,9 @@ impl Globals { primary_selection_manager: globals.bind(&qh, 1..=1, ()).ok(), shm: globals.bind(&qh, 1..=1, ()).unwrap(), seat, - wm_base: globals.bind(&qh, 1..=5, ()).unwrap(), + // Accept any xdg_wm_base version up to 6, which added the `suspended` + // toplevel state; older compositors bind at their own version. + wm_base: globals.bind(&qh, 1..=6, ()).unwrap(), viewporter: globals.bind(&qh, 1..=1, ()).ok(), fractional_scale_manager: globals.bind(&qh, 1..=1, ()).ok(), decoration_manager: globals.bind(&qh, 1..=1, ()).ok(), @@ -787,7 +789,7 @@ impl WaylandClient { let event_loop = EventLoop::::try_new().unwrap(); - let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, power_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); handle @@ -809,10 +811,14 @@ impl WaylandClient { handle .insert_source( - wake_receiver, + power_receiver, |event, _, client: &mut WaylandClientStatePtr| { - if let calloop::channel::Event::Msg(()) = event { - client.get_client().borrow_mut().common.handle_system_wake(); + if let calloop::channel::Event::Msg(event) = event { + client + .get_client() + .borrow_mut() + .common + .handle_system_power_event(event); } }, ) diff --git a/crates/gpui_linux/src/linux/wayland/window.rs b/crates/gpui_linux/src/linux/wayland/window.rs index c0a7a6410f..a66547aa0c 100644 --- a/crates/gpui_linux/src/linux/wayland/window.rs +++ b/crates/gpui_linux/src/linux/wayland/window.rs @@ -37,7 +37,7 @@ use gpui::{ Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowControls, WindowDecorations, WindowKind, WindowParams, + WindowControls, WindowDecorations, WindowKind, WindowParams, WindowVisibility, layer_shell::{Anchor, LayerShellNotSupportedError}, popup::PopupOptions, px, size, @@ -49,6 +49,7 @@ pub(crate) struct Callbacks { request_frame: Option>, input: Option gpui::DispatchEventResult>>, active_status_change: Option>, + visibility_change: Option>, hover_status_change: Option>, resize: Option, f32)>>, moved: Option>, @@ -91,6 +92,7 @@ struct InProgressConfigure { fullscreen: bool, maximized: bool, resizing: bool, + visibility: WindowVisibility, tiling: Tiling, } @@ -116,6 +118,8 @@ pub struct WaylandWindowState { background_appearance: WindowBackgroundAppearance, fullscreen: bool, maximized: bool, + /// `Hidden` while the `xdg_toplevel` `suspended` state (xdg-shell v6) is set. + visibility: WindowVisibility, tiling: Tiling, window_bounds: Bounds, client: WaylandClientStatePtr, @@ -613,6 +617,7 @@ impl WaylandWindowState { background_appearance: WindowBackgroundAppearance::Opaque, fullscreen: false, maximized: false, + visibility: WindowVisibility::Visible, tiling: Tiling::default(), window_bounds: options.bounds, in_progress_configure: None, @@ -1078,27 +1083,37 @@ impl WaylandWindowStatePtr { state.fullscreen = configure.fullscreen; state.maximized = configure.maximized; state.tiling = configure.tiling; + let visibility_changed = state.visibility != configure.visibility; + state.visibility = configure.visibility; // Limit interactive resizes to once per vblank - if configure.resizing && state.resize_throttle { + let throttled = configure.resizing && state.resize_throttle; + if throttled { state.surface_state.ack_configure(serial); - return; - } else if configure.resizing { - state.resize_throttle = true; - } - if !configure.fullscreen && !configure.maximized { - configure.size = if got_unmaximized { - Some(state.window_bounds.size) - } else { - compute_outer_size(state.inset(), configure.size, state.tiling) - }; - if let Some(size) = configure.size { - state.window_bounds = Bounds { - origin: Point::default(), - size, + } else { + if configure.resizing { + state.resize_throttle = true; + } + if !configure.fullscreen && !configure.maximized { + configure.size = if got_unmaximized { + Some(state.window_bounds.size) + } else { + compute_outer_size(state.inset(), configure.size, state.tiling) }; + if let Some(size) = configure.size { + state.window_bounds = Bounds { + origin: Point::default(), + size, + }; + } } } drop(state); + if visibility_changed { + self.report_visibility(configure.visibility); + } + if throttled { + return; + } if let Some(size) = configure.size { self.resize(size); } @@ -1192,6 +1207,7 @@ impl WaylandWindowStatePtr { let mut fullscreen = false; let mut maximized = false; let mut resizing = false; + let mut visibility = WindowVisibility::Visible; for state in states { match state { @@ -1202,6 +1218,7 @@ impl WaylandWindowStatePtr { fullscreen = true; } xdg_toplevel::State::Resizing => resizing = true, + xdg_toplevel::State::Suspended => visibility = WindowVisibility::Hidden, xdg_toplevel::State::TiledTop => { tiling.top = true; } @@ -1230,6 +1247,7 @@ impl WaylandWindowStatePtr { fullscreen, maximized, resizing, + visibility, tiling, }); @@ -1304,6 +1322,7 @@ impl WaylandWindowStatePtr { fullscreen: false, maximized: false, resizing: false, + visibility: WindowVisibility::Visible, tiling: Tiling::default(), }); drop(state); @@ -1338,6 +1357,7 @@ impl WaylandWindowStatePtr { fullscreen: false, maximized: false, resizing: false, + visibility: WindowVisibility::Visible, tiling: Tiling::default(), }); @@ -1556,6 +1576,14 @@ impl WaylandWindowStatePtr { } } + fn report_visibility(&self, visibility: WindowVisibility) { + let callback = self.callbacks.borrow_mut().visibility_change.take(); + if let Some(mut callback) = callback { + callback(visibility); + self.callbacks.borrow_mut().visibility_change = Some(callback); + } + } + pub fn set_appearance(&mut self, appearance: WindowAppearance) { self.state.borrow_mut().appearance = appearance; @@ -1781,6 +1809,10 @@ impl PlatformWindow for WaylandWindow { self.borrow().active } + fn visibility(&self) -> WindowVisibility { + self.borrow().visibility + } + fn is_hovered(&self) -> bool { self.borrow().hovered } @@ -1867,6 +1899,10 @@ impl PlatformWindow for WaylandWindow { self.0.callbacks.borrow_mut().active_status_change = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + self.0.callbacks.borrow_mut().visibility_change = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.0.callbacks.borrow_mut().hover_status_change = Some(callback); } diff --git a/crates/gpui_linux/src/linux/x11/client.rs b/crates/gpui_linux/src/linux/x11/client.rs index 9b35125da1..28c4189ca6 100644 --- a/crates/gpui_linux/src/linux/x11/client.rs +++ b/crates/gpui_linux/src/linux/x11/client.rs @@ -12,7 +12,7 @@ use http_client::Url; use log::Level; use smallvec::SmallVec; use std::{ - cell::RefCell, + cell::{RefCell, RefMut}, collections::{BTreeMap, HashSet}, ops::Deref, path::PathBuf, @@ -63,7 +63,7 @@ use gpui::{ AnyWindowHandle, Bounds, ClipboardItem, CursorStyle, DisplayId, FileDropEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Pixels, PlatformDisplay, PlatformInput, PlatformKeyboardLayout, PlatformWindow, Point, RequestFrameOptions, ScrollDelta, Size, - TouchPhase, WindowButtonLayout, WindowParams, point, px, + TouchPhase, WindowButtonLayout, WindowParams, WindowVisibility, point, px, }; use gpui_wgpu::{CompositorGpuHint, GpuContext}; @@ -90,6 +90,17 @@ impl WindowRef { pub fn handle(&self) -> AnyWindowHandle { self.window.state.borrow().handle } + + /// Whether the X server is presenting this window. Compositing window + /// managers rarely report `FULLY_OBSCURED`, so under them this is the + /// mapped state alone. + fn visibility(&self) -> WindowVisibility { + if self.is_mapped && !matches!(self.last_visibility, Visibility::FULLY_OBSCURED) { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } + } } impl Deref for WindowRef { @@ -309,7 +320,7 @@ impl X11Client { pub(crate) fn new() -> anyhow::Result { let event_loop = EventLoop::try_new()?; - let (common, main_receiver, wake_receiver) = LinuxCommon::new(event_loop.get_signal()); + let (common, main_receiver, power_receiver) = LinuxCommon::new(event_loop.get_signal()); let handle = event_loop.handle(); @@ -339,13 +350,17 @@ impl X11Client { })?; handle - .insert_source(wake_receiver, |event, _, client: &mut X11Client| { - if let calloop::channel::Event::Msg(()) = event { - client.0.borrow_mut().common.handle_system_wake(); + .insert_source(power_receiver, |event, _, client: &mut X11Client| { + if let calloop::channel::Event::Msg(event) = event { + client + .0 + .borrow_mut() + .common + .handle_system_power_event(event); } }) .map_err(|err| { - anyhow!("Failed to initialize event loop handling of wake events: {err:?}") + anyhow!("Failed to initialize event loop handling of sleep/wake events: {err:?}") })?; let (xcb_connection, x_root_index) = XCBConnection::connect(None)?; @@ -801,21 +816,21 @@ impl X11Client { if let Some(window_ref) = state.windows.get_mut(&event.window) { window_ref.is_mapped = false; } - state.update_refresh_loop(event.window); + handle_visibility_changed(state, event.window); } Event::MapNotify(event) => { let mut state = self.0.borrow_mut(); if let Some(window_ref) = state.windows.get_mut(&event.window) { window_ref.is_mapped = true; } - state.update_refresh_loop(event.window); + handle_visibility_changed(state, event.window); } Event::VisibilityNotify(event) => { let mut state = self.0.borrow_mut(); if let Some(window_ref) = state.windows.get_mut(&event.window) { window_ref.last_visibility = event.state; } - state.update_refresh_loop(event.window); + handle_visibility_changed(state, event.window); } Event::ClientMessage(event) => { let window = self.get_window(event.window)?; @@ -1903,8 +1918,7 @@ impl X11ClientState { let Some(window_ref) = self.windows.get_mut(&x_window) else { return; }; - let is_visible = window_ref.is_mapped - && !matches!(window_ref.last_visibility, Visibility::FULLY_OBSCURED); + let is_visible = window_ref.visibility().is_visible(); match (is_visible, window_ref.refresh_state.take()) { (false, refresh_state @ Some(RefreshState::Hidden { .. })) | (false, refresh_state @ None) @@ -2163,6 +2177,20 @@ pub fn mode_refresh_rate(mode: &randr::ModeInfo) -> Duration { Duration::from_micros(micros) } +/// Applies a mapped/obscured change to the refresh loop and reports the +/// resulting visibility to the window. Consumes the client borrow because the +/// visibility callback re-enters GPUI. +fn handle_visibility_changed(mut state: RefMut<'_, X11ClientState>, x_window: xproto::Window) { + state.update_refresh_loop(x_window); + let Some(window_ref) = state.windows.get(&x_window) else { + return; + }; + let visibility = window_ref.visibility(); + let window = window_ref.window.clone(); + drop(state); + window.set_visibility(visibility); +} + fn fp3232_to_f32(value: xinput::Fp3232) -> f32 { value.integral as f32 + value.frac as f32 / u32::MAX as f32 } diff --git a/crates/gpui_linux/src/linux/x11/window.rs b/crates/gpui_linux/src/linux/x11/window.rs index d6db24e2e0..56b3d8a906 100644 --- a/crates/gpui_linux/src/linux/x11/window.rs +++ b/crates/gpui_linux/src/linux/x11/window.rs @@ -7,7 +7,8 @@ use gpui::{ Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, ScaledPixels, Scene, Size, Tiling, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, - WindowDecorations, WindowKind, WindowParams, popup::PopupNotSupportedError, px, + WindowDecorations, WindowKind, WindowParams, WindowVisibility, popup::PopupNotSupportedError, + px, }; use gpui_wgpu::{CompositorGpuHint, WgpuRenderer, WgpuSurfaceConfig}; @@ -245,6 +246,7 @@ pub struct Callbacks { request_frame: Option>, input: Option gpui::DispatchEventResult>>, active_status_change: Option>, + visibility_change: Option>, hovered_status_change: Option>, resize: Option, f32)>>, moved: Option>, @@ -277,6 +279,9 @@ pub struct X11WindowState { maximized_horizontal: bool, hidden: bool, active: bool, + /// Owned by the client's `WindowRef`, which combines the mapped state with + /// `VisibilityNotify`; this is the last value it reported. + visibility: WindowVisibility, hovered: bool, pub(crate) force_render_after_recovery: bool, fullscreen: bool, @@ -827,6 +832,8 @@ impl X11WindowState { atoms: *atoms, input_handler: None, active: false, + // The window is not mapped until the client sees `MapNotify`. + visibility: WindowVisibility::Hidden, hovered: false, force_render_after_recovery: false, fullscreen: false, @@ -1338,6 +1345,17 @@ impl X11WindowStatePtr { } } + pub fn set_visibility(&self, visibility: WindowVisibility) { + if std::mem::replace(&mut self.state.borrow_mut().visibility, visibility) == visibility { + return; + } + let callback = self.callbacks.borrow_mut().visibility_change.take(); + if let Some(mut fun) = callback { + fun(visibility); + self.callbacks.borrow_mut().visibility_change = Some(fun); + } + } + pub fn set_appearance(&mut self, appearance: WindowAppearance) { let mut state = self.state.borrow_mut(); state.appearance = appearance; @@ -1536,6 +1554,10 @@ impl PlatformWindow for X11Window { self.0.state.borrow().active } + fn visibility(&self) -> WindowVisibility { + self.0.state.borrow().visibility + } + fn is_hovered(&self) -> bool { self.0.state.borrow().hovered } @@ -1683,6 +1705,10 @@ impl PlatformWindow for X11Window { self.0.callbacks.borrow_mut().active_status_change = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + self.0.callbacks.borrow_mut().visibility_change = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.0.callbacks.borrow_mut().hovered_status_change = Some(callback); } diff --git a/crates/gpui_macos/src/platform.rs b/crates/gpui_macos/src/platform.rs index c3ae3b3b18..bda04696c2 100644 --- a/crates/gpui_macos/src/platform.rs +++ b/crates/gpui_macos/src/platform.rs @@ -44,8 +44,11 @@ use objc::{ runtime::{Class, Object, Sel}, sel, sel_impl, }; -use objc2::MainThreadMarker; -use objc2_app_kit::{NSModalResponse, NSModalResponseOK, NSOpenPanel, NSSavePanel, NSWorkspace}; +use objc2::{MainThreadMarker, runtime::AnyObject}; +use objc2_app_kit::{ + NSModalResponse, NSModalResponseOK, NSOpenPanel, NSSavePanel, NSWorkspace, + NSWorkspaceDidWakeNotification, NSWorkspaceWillSleepNotification, +}; use objc2_foundation::NSActivityOptions; use parking_lot::Mutex; use ptr::null_mut; @@ -156,6 +159,11 @@ unsafe fn build_classes() { on_thermal_state_change as extern "C" fn(&mut Object, Sel, id), ); + decl.add_method( + sel!(onSystemSleep:), + on_system_sleep as extern "C" fn(&mut Object, Sel, id), + ); + decl.add_method( sel!(onSystemWake:), on_system_wake as extern "C" fn(&mut Object, Sel, id), @@ -179,8 +187,9 @@ pub(crate) struct MacPlatformState { reopen: Option>, on_keyboard_layout_change: Option>, on_thermal_state_change: Option>, + on_system_sleep: Option>, on_system_wake: Option>, - system_wake_observer_registered: bool, + system_power_observers_registered: bool, quit: Option bool>>, menu_command: Option>, validate_menu_command: Option bool>>, @@ -236,8 +245,9 @@ impl MacPlatform { dock_menu: None, on_keyboard_layout_change: None, on_thermal_state_change: None, + on_system_sleep: None, on_system_wake: None, - system_wake_observer_registered: false, + system_power_observers_registered: false, menus: None, keyboard_mapper, cursor_visible: Arc::new(AtomicBool::new(true)), @@ -478,6 +488,28 @@ impl MacPlatform { version.patchVersion, ) } + + // Before the app finishes launching there is no delegate yet; + // `did_finish_launching` registers the observers then. + fn ensure_system_power_observers(&self) { + if self.0.lock().system_power_observers_registered { + return; + } + + // The shared application must be created through `APP_CLASS`, or `run` + // finds a plain `NSApplication` without the `platform` ivar; only the + // delegate lookup goes through the typed binding. + // SAFETY: APP_CLASS is registered during startup and `sharedApplication` + // returns a live NSApplication instance. + let delegate = unsafe { + let app: id = msg_send![APP_CLASS, sharedApplication]; + (*(app as *const objc2_app_kit::NSApplication)).delegate() + }; + if let Some(delegate) = delegate { + register_system_power_observers(delegate.as_ref()); + self.0.lock().system_power_observers_registered = true; + } + } } impl Platform for MacPlatform { @@ -974,23 +1006,14 @@ impl Platform for MacPlatform { self.0.lock().on_thermal_state_change = Some(callback); } - fn on_system_wake(&self, callback: Box) { - let mut state = self.0.lock(); - state.on_system_wake = Some(callback); - if state.system_wake_observer_registered { - return; - } - drop(state); + fn on_system_sleep(&self, callback: Box) { + self.0.lock().on_system_sleep = Some(callback); + self.ensure_system_power_observers(); + } - // SAFETY: APP_CLASS is registered during startup and returns the shared NSApplication. - unsafe { - let app: id = msg_send![APP_CLASS, sharedApplication]; - let delegate: id = msg_send![app, delegate]; - if delegate != nil { - register_system_wake_observer(delegate); - self.0.lock().system_wake_observer_registered = true; - } - } + fn on_system_wake(&self, callback: Box) { + self.0.lock().on_system_wake = Some(callback); + self.ensure_system_power_observers(); } fn thermal_state(&self) -> ThermalState { @@ -1313,13 +1336,16 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { object: process_info ]; - let observer = this as *mut Object as id; + // SAFETY: `this` is a live Objective-C object; only the pointer's type changes. + let observer = &*(this as *mut Object as *const AnyObject); let platform = get_mac_platform(this); let callback = { let mut state = platform.0.lock(); - if state.on_system_wake.is_some() && !state.system_wake_observer_registered { - register_system_wake_observer(observer); - state.system_wake_observer_registered = true; + if (state.on_system_sleep.is_some() || state.on_system_wake.is_some()) + && !state.system_power_observers_registered + { + register_system_power_observers(observer); + state.system_power_observers_registered = true; } state.finish_launching.take() }; @@ -1329,17 +1355,25 @@ extern "C" fn did_finish_launching(this: &mut Object, _: Sel, _: id) { } } -unsafe fn register_system_wake_observer(observer: id) { - // SAFETY: observer is an Objective-C object implementing onSystemWake:. +/// `observer` must be the app delegate, whose class declares `onSystemSleep:` +/// and `onSystemWake:` (see `build_classes`). +fn register_system_power_observers(observer: &AnyObject) { + let center = NSWorkspace::sharedWorkspace().notificationCenter(); + // SAFETY: both selectors are declared on the delegate class with the + // notification-handler signature these observers are invoked with. unsafe { - let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace]; - let workspace_center: *mut Object = msg_send![workspace, notificationCenter]; - let wake_name = ns_string("NSWorkspaceDidWakeNotification"); - let _: () = msg_send![workspace_center, addObserver: observer - selector: sel!(onSystemWake:) - name: wake_name - object: nil - ]; + center.addObserver_selector_name_object( + observer, + objc2::sel!(onSystemSleep:), + Some(NSWorkspaceWillSleepNotification), + None, + ); + center.addObserver_selector_name_object( + observer, + objc2::sel!(onSystemWake:), + Some(NSWorkspaceDidWakeNotification), + None, + ); } } @@ -1406,6 +1440,30 @@ extern "C" fn on_thermal_state_change(this: &mut Object, _: Sel, _: id) { } } +// Deferred to the main queue like the wake and thermal notifications. The +// machine keeps running the main run loop for well over a frame after the +// observers return, so the block runs before it suspends. +extern "C" fn on_system_sleep(this: &mut Object, _: Sel, _: id) { + // SAFETY: this is the registered app delegate carrying MAC_PLATFORM_IVAR. + let platform = unsafe { get_mac_platform(this) }; + let platform_ptr = platform as *const MacPlatform as *mut c_void; + // SAFETY: platform lives for the process lifetime while callbacks are registered. + unsafe { + DispatchQueue::main().exec_async_f(platform_ptr, on_system_sleep); + } + + extern "C" fn on_system_sleep(context: *mut c_void) { + // SAFETY: context is the MacPlatform pointer queued above. + let platform = unsafe { &*(context as *const MacPlatform) }; + let mut lock = platform.0.lock(); + if let Some(mut callback) = lock.on_system_sleep.take() { + drop(lock); + callback(); + platform.0.lock().on_system_sleep.get_or_insert(callback); + } + } +} + extern "C" fn on_system_wake(this: &mut Object, _: Sel, _: id) { // SAFETY: this is the registered app delegate carrying MAC_PLATFORM_IVAR. let platform = unsafe { get_mac_platform(this) }; diff --git a/crates/gpui_macos/src/window.rs b/crates/gpui_macos/src/window.rs index 7d06dcdabd..76f413a4a2 100644 --- a/crates/gpui_macos/src/window.rs +++ b/crates/gpui_macos/src/window.rs @@ -30,7 +30,7 @@ use gpui::{ PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, SharedString, Size, SystemWindowTab, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowKind, - WindowParams, point, px, size, + WindowParams, WindowVisibility, point, px, size, }; #[cfg(any(test, feature = "test-support"))] use image::RgbaImage; @@ -671,6 +671,10 @@ struct MacWindowState { request_frame_callback: Option>, event_callback: Option gpui::DispatchEventResult>>, activate_callback: Option>, + visibility_callback: Option>, + // `None` until a callback is registered, so notifications during + // construction are not queued for delivery to a callback registered later. + last_visibility: Option, resize_callback: Option, f32)>>, moved_callback: Option>, should_close_callback: Option bool>>, @@ -1104,6 +1108,8 @@ impl MacWindow { request_frame_callback: None, event_callback: None, activate_callback: None, + visibility_callback: None, + last_visibility: None, resize_callback: None, moved_callback: None, should_close_callback: None, @@ -1381,6 +1387,9 @@ impl Drop for MacWindow { this.native_window.setDelegate_(nil); } this.input_handler.take(); + // A delivery task queued by `report_visibility` may still run after the + // GPUI window is gone; without a callback it has nothing to notify. + this.visibility_callback.take(); this.foreground_executor .spawn(async move { unsafe { @@ -1807,6 +1816,10 @@ impl PlatformWindow for MacWindow { unsafe { self.0.lock().native_window.isKeyWindow() == YES } } + fn visibility(&self) -> WindowVisibility { + visibility(&self.0.lock()) + } + // is_hovered is unused on macOS. See Window::is_window_hovered. fn is_hovered(&self) -> bool { false @@ -2005,6 +2018,12 @@ impl PlatformWindow for MacWindow { self.0.as_ref().lock().activate_callback = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + let mut state = self.0.lock(); + state.last_visibility = Some(visibility(&state)); + state.visibility_callback = Some(callback); + } + fn on_hover_status_change(&self, _: Box) {} fn on_resize(&self, callback: Box, f32)>) { @@ -2887,9 +2906,55 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { } } +fn visibility(state: &MacWindowState) -> WindowVisibility { + let is_visible = unsafe { + state + .native_window + .occlusionState() + .contains(NSWindowOcclusionState::NSWindowOcclusionStateVisible) + }; + if is_visible { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } +} + +fn report_visibility(window_state: &Arc>) { + let state = window_state.lock(); + if state.last_visibility.is_none() { + return; + } + let executor = state.foreground_executor.clone(); + drop(state); + + // AppKit can notify while GPUI is updating a window. Deliver observers + // after that update completes, as activation notifications do. The state + // is read at delivery rather than captured here so a burst of + // notifications collapses to the final value. + executor + .spawn({ + let window_state = window_state.clone(); + async move { + let mut state = window_state.lock(); + let visibility = visibility(&state); + if state.last_visibility == Some(visibility) { + return; + } + state.last_visibility = Some(visibility); + if let Some(mut callback) = state.visibility_callback.take() { + drop(state); + callback(visibility); + window_state.lock().visibility_callback = Some(callback); + } + } + }) + .detach(); +} + extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; - let lock = &mut *window_state.lock(); + let mut lock = window_state.lock(); unsafe { if lock .native_window @@ -2902,6 +2967,10 @@ extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { lock.stop_display_link(); } } + drop(lock); + // The only visibility source: AppKit posts this for covering, minimizing, + // hiding, Space switches, and display sleep alike. + report_visibility(&window_state); } extern "C" fn window_did_resize(this: &Object, _: Sel, _: id) { diff --git a/crates/gpui_web/src/platform.rs b/crates/gpui_web/src/platform.rs index c527e4cbd2..e3a937e105 100644 --- a/crates/gpui_web/src/platform.rs +++ b/crates/gpui_web/src/platform.rs @@ -493,6 +493,10 @@ impl Platform for WebPlatform { self.callbacks.borrow_mut().reopen = Some(callback); } + // Browsers expose no system sleep or wake signal; the nearest thing is the + // Page Visibility API, which drives `WindowVisibility` instead. + fn on_system_sleep(&self, _callback: Box) {} + fn on_system_wake(&self, _callback: Box) {} fn set_menus(&self, _menus: Vec

, _keymap: &Keymap) {} diff --git a/crates/gpui_web/src/window.rs b/crates/gpui_web/src/window.rs index 61b5a7c651..bf00bd2d4b 100644 --- a/crates/gpui_web/src/window.rs +++ b/crates/gpui_web/src/window.rs @@ -14,7 +14,7 @@ use gpui::{ PlatformInputHandler, PlatformWindow, Point, PromptButton, PromptLevel, RequestFrameOptions, ResizeEdge, Scene, Size, TextInputConfiguration, TextInputStateChange, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, WindowControls, WindowDecorations, - WindowInsets, WindowParams, px, + WindowInsets, WindowParams, WindowVisibility, px, }; use gpui_wgpu::{WgpuContext, WgpuRenderer, WgpuSurfaceConfig, wgpu}; use wasm_bindgen::prelude::*; @@ -24,6 +24,7 @@ pub(crate) struct WebWindowCallbacks { pub(crate) request_frame: Option>, pub(crate) input: Option DispatchEventResult>>, pub(crate) active_status_change: Option>, + pub(crate) visibility_change: Option>, pub(crate) hover_status_change: Option>, pub(crate) resize: Option, f32)>>, pub(crate) visual_viewport_changed: Option>, @@ -43,6 +44,7 @@ pub(crate) struct WebWindowMutableState { pub(crate) input_handler: Option, pub(crate) is_fullscreen: bool, pub(crate) is_active: bool, + pub(crate) visibility: WindowVisibility, pub(crate) is_hovered: bool, pub(crate) mouse_position: Point, pub(crate) modifiers: Modifiers, @@ -194,6 +196,7 @@ impl WebWindow { input_handler: None, is_fullscreen: false, is_active: true, + visibility: document_visibility(&browser_window), is_hovered: false, mouse_position: Point::default(), modifiers: Modifiers::default(), @@ -514,27 +517,24 @@ impl WebWindowInner { document.as_ref(), "visibilitychange", move |_event: JsValue| { - let is_visible = this - .browser_window - .document() - .map(|doc| { - let state_str: String = - js_sys::Reflect::get(&doc, &"visibilityState".into()) - .ok() - .and_then(|v| v.as_string()) - .unwrap_or_default(); - state_str == "visible" - }) - .unwrap_or(true); + let visibility = document_visibility(&this.browser_window); + let is_visible = visibility.is_visible(); - { + let visibility_changed = { let mut state = this.state.borrow_mut(); state.is_active = is_visible; - } + std::mem::replace(&mut state.visibility, visibility) != visibility + }; this.with_callback( |callbacks| &mut callbacks.active_status_change, |callback| callback(is_visible), ); + if visibility_changed { + this.with_callback( + |callbacks| &mut callbacks.visibility_change, + |callback| callback(visibility), + ); + } }, )) } @@ -639,6 +639,22 @@ fn current_appearance(browser_window: &web_sys::Window) -> WindowAppearance { } } +/// The Page Visibility API: `hidden` when the tab is in the background or the +/// browser window is minimized. A missing document reads as visible. +fn document_visibility(browser_window: &web_sys::Window) -> WindowVisibility { + let is_visible = browser_window + .document() + .and_then(|document| js_sys::Reflect::get(&document, &"visibilityState".into()).ok()) + .and_then(|value| value.as_string()) + .is_none_or(|state| state == "visible"); + + if is_visible { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } +} + struct MqlHandle { mql: web_sys::MediaQueryList, _closure: Closure, @@ -851,6 +867,10 @@ impl PlatformWindow for WebWindow { self.inner.state.borrow().is_active } + fn visibility(&self) -> WindowVisibility { + self.inner.state.borrow().visibility + } + fn is_hovered(&self) -> bool { self.inner.state.borrow().is_hovered } @@ -920,6 +940,10 @@ impl PlatformWindow for WebWindow { self.inner.callbacks.borrow_mut().active_status_change = Some(callback); } + fn on_visibility_change(&self, callback: Box) { + self.inner.callbacks.borrow_mut().visibility_change = Some(callback); + } + fn on_hover_status_change(&self, callback: Box) { self.inner.callbacks.borrow_mut().hover_status_change = Some(callback); } diff --git a/crates/gpui_windows/src/events.rs b/crates/gpui_windows/src/events.rs index e700cb0fc5..f1910b56b5 100644 --- a/crates/gpui_windows/src/events.rs +++ b/crates/gpui_windows/src/events.rs @@ -236,7 +236,11 @@ impl WindowsWindowInner { Some(0) } - fn handle_size_msg(&self, wparam: WPARAM, lparam: LPARAM) -> Option { + fn handle_size_msg(self: &Rc, wparam: WPARAM, lparam: LPARAM) -> Option { + // Minimizing and restoring both arrive as `WM_SIZE`; the deferred report + // reads `IsIconic` at delivery, so one call covers both directions. + self.report_visibility(); + // Don't resize the renderer when the window is minimized, but record that it was minimized so // that on restore the swap chain can be recreated via `update_drawable_size_even_if_unchanged`. if wparam.0 == SIZE_MINIMIZED as usize { @@ -1261,13 +1265,42 @@ impl WindowsWindowInner { Some(0) } - fn handle_window_visibility_changed(&self, handle: HWND, wparam: WPARAM) -> Option { + fn handle_window_visibility_changed( + self: &Rc, + handle: HWND, + wparam: WPARAM, + ) -> Option { + self.report_visibility(); if wparam.0 == 1 { self.draw_window(handle, false); } None } + // The window procedure can run while GPUI is updating this window (e.g. + // `ShowWindow` from an action handler), so deliver observers after that + // update completes, as activation does. The state is read at delivery so + // a burst of messages collapses to the final value. + fn report_visibility(self: &Rc) { + if self.state.last_visibility.get().is_none() { + return; + } + let this = self.clone(); + self.executor + .spawn(async move { + let visibility = this.visibility(); + if this.state.last_visibility.get() == Some(visibility) { + return; + } + this.state.last_visibility.set(Some(visibility)); + if let Some(mut callback) = this.state.callbacks.visibility_change.take() { + callback(visibility); + this.state.callbacks.visibility_change.set(Some(callback)); + } + }) + .detach(); + } + fn handle_device_lost(&self, lparam: LPARAM) -> Option { let devices = lparam.0 as *const DirectXDevices; let devices = unsafe { &*devices }; diff --git a/crates/gpui_windows/src/platform.rs b/crates/gpui_windows/src/platform.rs index 9855667b90..ea43485970 100644 --- a/crates/gpui_windows/src/platform.rs +++ b/crates/gpui_windows/src/platform.rs @@ -93,6 +93,7 @@ struct PlatformCallbacks { will_open_app_menu: Cell>>, validate_app_menu_command: Cell bool>>>, keyboard_layout_change: Cell>>, + system_sleep: Cell>>, system_wake: Cell>>, } @@ -743,6 +744,10 @@ impl Platform for WindowsPlatform { self.inner.state.callbacks.reopen.set(Some(callback)); } + fn on_system_sleep(&self, callback: Box) { + self.inner.state.callbacks.system_sleep.set(Some(callback)); + } + fn on_system_wake(&self, callback: Box) { self.inner.state.callbacks.system_wake.set(Some(callback)); let mut notification = self.suspend_resume_notification.borrow_mut(); @@ -1224,8 +1229,14 @@ impl WindowsPlatformInner { } fn handle_power_broadcast(&self, wparam: WPARAM) -> Option { - if wparam.0 as u32 == PBT_APMRESUMEAUTOMATIC { - self.with_callback(|callbacks| &callbacks.system_wake, |callback| callback()); + match wparam.0 as u32 { + PBT_APMSUSPEND => { + self.with_callback(|callbacks| &callbacks.system_sleep, |callback| callback()); + } + PBT_APMRESUMEAUTOMATIC => { + self.with_callback(|callbacks| &callbacks.system_wake, |callback| callback()); + } + _ => {} } Some(1) } diff --git a/crates/gpui_windows/src/window.rs b/crates/gpui_windows/src/window.rs index 37e9d9a176..9988e44f6e 100644 --- a/crates/gpui_windows/src/window.rs +++ b/crates/gpui_windows/src/window.rs @@ -60,6 +60,7 @@ pub struct WindowsWindowState { pub last_reported_modifiers: Cell>, pub last_reported_capslock: Cell>, pub hovered: Cell, + pub last_visibility: Cell>, pub direct_manipulation: DirectManipulationHandler, pub renderer: RefCell, @@ -172,6 +173,7 @@ impl WindowsWindowState { last_reported_modifiers: Cell::new(last_reported_modifiers), last_reported_capslock: Cell::new(last_reported_capslock), hovered: Cell::new(hovered), + last_visibility: Cell::new(None), renderer: RefCell::new(renderer), force_render_pending: Cell::new(false), click_state, @@ -249,6 +251,19 @@ impl WindowsWindowState { } impl WindowsWindowInner { + /// Whether the window is being presented: shown and not minimized. Windows + /// has no notification for a window fully covered by other windows, so + /// that case reports `Visible`. + pub(crate) fn visibility(&self) -> WindowVisibility { + let is_visible = + unsafe { IsWindowVisible(self.hwnd).as_bool() && !IsIconic(self.hwnd).as_bool() }; + if is_visible { + WindowVisibility::Visible + } else { + WindowVisibility::Hidden + } + } + fn new(context: &mut WindowCreateContext, hwnd: HWND, cs: &CREATESTRUCTW) -> Result> { let state = WindowsWindowState::new( hwnd, @@ -380,6 +395,7 @@ pub(crate) struct Callbacks { pub(crate) request_frame: Cell>>, pub(crate) input: Cell DispatchEventResult>>>, pub(crate) active_status_change: Cell>>, + pub(crate) visibility_change: Cell>>, pub(crate) hovered_status_change: Cell>>, pub(crate) resize: Cell, f32)>>>, pub(crate) moved: Cell>>, @@ -589,6 +605,9 @@ impl rwh::HasDisplayHandle for WindowsWindow { impl Drop for WindowsWindow { fn drop(&mut self) { + // `DestroyWindow` below sends `WM_SHOWWINDOW`; without a callback the + // resulting visibility report has nothing to notify. + self.0.state.callbacks.visibility_change.take(); // clone this `Rc` to prevent early release of the pointer let this = self.0.clone(); self.0 @@ -854,6 +873,10 @@ impl PlatformWindow for WindowsWindow { self.0.hwnd == unsafe { GetActiveWindow() } } + fn visibility(&self) -> WindowVisibility { + self.0.visibility() + } + fn is_hovered(&self) -> bool { self.state.hovered.get() } @@ -943,6 +966,11 @@ impl PlatformWindow for WindowsWindow { .set(Some(callback)); } + fn on_visibility_change(&self, callback: Box) { + self.0.state.last_visibility.set(Some(self.0.visibility())); + self.0.state.callbacks.visibility_change.set(Some(callback)); + } + fn on_hover_status_change(&self, callback: Box) { self.0 .state