diff --git a/frontends/rioterm/src/application.rs b/frontends/rioterm/src/application.rs index 745502770a..b90778b2cc 100644 --- a/frontends/rioterm/src/application.rs +++ b/frontends/rioterm/src/application.rs @@ -225,9 +225,9 @@ impl Application<'_> { } #[cfg(not(target_os = "macos"))] { - let _ = window.request_inner_size( - rio_window::dpi::PhysicalSize::new(width, height), - ); + let _ = window.request_inner_size(rio_window::dpi::PhysicalSize::new( + width, height, + )); window.set_outer_position(rio_window::dpi::PhysicalPosition::new( x, mpos.y, )); @@ -235,6 +235,14 @@ impl Application<'_> { } window.set_visible(true); window.focus_window(); + if let Some(route) = self.router.routes.get_mut(&id) { + route.window.is_occluded = false; + route + .window + .screen + .context_manager + .set_window_visibility(true); + } } /// Show, focus or hide the quake window; create it on first use. @@ -264,6 +272,12 @@ impl Application<'_> { self.show_quake_window(id, event_loop); } else if window.has_focus() { window.set_visible(false); + let visible = route.window.is_potentially_visible(); + route + .window + .screen + .context_manager + .set_window_visibility(visible); #[cfg(target_os = "macos")] if let Some(pid) = self.quake_previous_app.take() { rio_window::platform::macos::activate_application(pid); @@ -276,7 +290,26 @@ impl Application<'_> { } impl ApplicationHandler for Application<'_> { - fn resumed(&mut self, _active_event_loop: &ActiveEventLoop) {} + fn resumed(&mut self, _active_event_loop: &ActiveEventLoop) { + for route in self.router.routes.values_mut() { + let visible = route.window.is_potentially_visible(); + route + .window + .screen + .context_manager + .set_window_visibility(visible); + } + } + + fn suspended(&mut self, _active_event_loop: &ActiveEventLoop) { + for route in self.router.routes.values_mut() { + route + .window + .screen + .context_manager + .set_window_visibility(false); + } + } fn new_events(&mut self, event_loop: &ActiveEventLoop, cause: StartCause) { if cause != StartCause::Init @@ -865,7 +898,7 @@ impl ApplicationHandler for Application<'_> { .window .screen .context_manager - .get_by_route_id(route_id) + .get_by_route_id_any(route_id) { item.val.messenger.send_bytes(text.into_bytes()); } @@ -1005,6 +1038,13 @@ impl ApplicationHandler for Application<'_> { } #[cfg(target_os = "macos")] RioEventType::Rio(RioEvent::Hide) => { + for route in self.router.routes.values_mut() { + route + .window + .screen + .context_manager + .set_window_visibility(false); + } event_loop.hide_application(); } #[cfg(target_os = "macos")] @@ -1014,6 +1054,12 @@ impl ApplicationHandler for Application<'_> { RioEventType::Rio(RioEvent::Minimize(set_minimize)) => { if let Some(route) = self.router.routes.get_mut(&window_id) { route.window.winit_window.set_minimized(set_minimize); + let visible = !set_minimize && route.window.is_potentially_visible(); + route + .window + .screen + .context_manager + .set_window_visibility(visible); } } RioEventType::Rio(RioEvent::ToggleFullScreen) => { @@ -1981,17 +2027,33 @@ impl ApplicationHandler for Application<'_> { let focus_changed = route.window.is_focused != focused; route.window.is_focused = focused; + if focused { + // A focused window cannot be minimized, hidden, or fully occluded. + route.window.is_occluded = false; + } if focus_changed { route.request_redraw(); } route.window.screen.on_focus_change(focused); + let visible = route.window.is_potentially_visible(); + route + .window + .screen + .context_manager + .set_window_visibility(visible); } WindowEvent::Occluded(occluded) => { let was_occluded = route.window.is_occluded; route.window.is_occluded = occluded; + let visible = route.window.is_potentially_visible(); + route + .window + .screen + .context_manager + .set_window_visibility(visible); // If window was occluded and is now visible, mark for one-time render if was_occluded && !occluded { @@ -2023,6 +2085,13 @@ impl ApplicationHandler for Application<'_> { } WindowEvent::Resized(new_size) => { + let visible = route.window.is_potentially_visible(); + route + .window + .screen + .context_manager + .set_window_visibility(visible); + if new_size.width == 0 || new_size.height == 0 { return; } diff --git a/frontends/rioterm/src/context/mod.rs b/frontends/rioterm/src/context/mod.rs index f1d644c1c6..f7fad9c579 100644 --- a/frontends/rioterm/src/context/mod.rs +++ b/frontends/rioterm/src/context/mod.rs @@ -148,6 +148,7 @@ pub struct ContextManager { contexts: SmallVec<[ContextGrid; DEFAULT_CONTEXT_CAPACITY]>, current_index: usize, current_route: usize, + window_visible: bool, #[allow(unused)] capacity: usize, event_proxy: T, @@ -412,6 +413,7 @@ impl ContextManager { Ok(ContextManager { current_index: 0, current_route: 0, + window_visible: true, contexts: smallvec![ContextGrid::new( initial_context, scaled_margin, @@ -450,6 +452,7 @@ impl ContextManager { Ok(ContextManager { current_index: 0, current_route: 0, + window_visible: true, contexts: smallvec![ContextGrid::new( initial_context, Margin::default(), @@ -800,6 +803,17 @@ impl ContextManager { self.contexts[self.current_index].get_by_route_id(route_id) } + /// Find a terminal by route across active and inactive tabs. + #[inline] + pub fn get_by_route_id_any( + &mut self, + route_id: usize, + ) -> Option<&mut ContextGridItem> { + self.contexts + .iter_mut() + .find_map(|context| context.get_by_route_id(route_id)) + } + #[inline] pub fn contexts_mut( &mut self, @@ -846,8 +860,16 @@ impl ContextManager { #[inline] pub fn set_current(&mut self, context_id: usize) { if context_id < self.contexts.len() { + let old_index = self.current_index; self.current_index = context_id; self.current_route = self.current().route_id; + + if old_index != context_id { + if let Some(context) = self.contexts.get(old_index) { + context.set_terminal_visibility(false); + } + self.contexts[context_id].set_terminal_visibility(self.window_visible); + } } } @@ -911,13 +933,12 @@ impl ContextManager { return; } - if self.contexts.len() - 1 == self.current_index { - self.current_index = 0; + let next_index = if self.contexts.len() - 1 == self.current_index { + 0 } else { - self.current_index += 1; - } - - self.current_route = self.current().route_id; + self.current_index + 1 + }; + self.set_current(next_index); } #[inline] @@ -928,13 +949,12 @@ impl ContextManager { return; } - if self.current_index == 0 { - self.current_index = self.contexts.len() - 1; + let previous_index = if self.current_index == 0 { + self.contexts.len() - 1 } else { - self.current_index -= 1; - } - - self.current_route = self.current().route_id; + self.current_index - 1 + }; + self.set_current(previous_index); } #[inline] @@ -1025,6 +1045,10 @@ impl ContextManager { &cloned_config, ) { Ok(new_context) => { + new_context + .terminal + .lock() + .set_visibility(self.window_visible); let new_route_id = new_context.route_id; if split_down { self.contexts[self.current_index].split_down(new_context, sugarloaf); @@ -1087,6 +1111,10 @@ impl ContextManager { &context_manager_config, ) { Ok(new_context) => { + new_context + .terminal + .lock() + .set_visibility(self.window_visible); let new_route_id = new_context.route_id; if split_down { self.contexts[self.current_index].split_down(new_context, sugarloaf); @@ -1162,6 +1190,10 @@ impl ContextManager { Ok(new_context) => { let previous_scaled_margin = self.contexts[self.current_index].scaled_margin; + new_context + .terminal + .lock() + .set_visibility(redirect && self.window_visible); self.contexts.push(ContextGrid::new( new_context, previous_scaled_margin, @@ -1170,6 +1202,7 @@ impl ContextManager { self.config.panel, )); if redirect { + self.contexts[self.current_index].set_terminal_visibility(false); self.current_index = last_index; self.current_route = self.current().route_id; } @@ -1188,10 +1221,25 @@ impl ContextManager { // Skip the current tab if idx == self.current_index { context.set_all_rich_text_visibility(sugarloaf, true); + context.set_terminal_visibility(self.window_visible); continue; } context.set_all_rich_text_visibility(sugarloaf, false); + context.set_terminal_visibility(false); + } + } + + /// Update host-window visibility and propagate the effective state to + /// every terminal view. Inactive tabs are never considered visible. + pub fn set_window_visibility(&mut self, visible: bool) { + if self.window_visible == visible { + return; + } + + self.window_visible = visible; + for (index, context) in self.contexts.iter().enumerate() { + context.set_terminal_visibility(visible && index == self.current_index); } } @@ -1203,11 +1251,21 @@ impl ContextManager { old_index: usize, new_index: usize, ) { + if old_index == new_index { + if let Some(context) = self.contexts.get(new_index) { + context.set_all_rich_text_visibility(sugarloaf, true); + context.set_terminal_visibility(self.window_visible); + } + return; + } + if let Some(old_context) = self.contexts.get(old_index) { old_context.set_all_rich_text_visibility(sugarloaf, false); + old_context.set_terminal_visibility(false); } if let Some(new_context) = self.contexts.get(new_index) { new_context.set_all_rich_text_visibility(sugarloaf, true); + new_context.set_terminal_visibility(self.window_visible); } } } @@ -1246,6 +1304,24 @@ pub fn process_open_url( pub mod test { use super::*; use crate::event::VoidListener; + use rio_backend::ansi::mode::PrivateMode; + use rio_backend::performer::handler::Handler; + use std::sync::{Arc, Mutex}; + + #[derive(Clone)] + struct TestListener { + events: Arc>>, + } + + impl EventListener for TestListener { + fn event(&self) -> (Option, bool) { + (None, false) + } + + fn send_event(&self, event: RioEvent, _id: WindowId) { + self.events.lock().unwrap().push(event); + } + } #[test] fn test_capacity() { @@ -1328,6 +1404,70 @@ pub mod test { assert_eq!(context_manager.current_index, 3); } + #[test] + fn visibility_follows_active_tab_and_window() { + let events = Arc::new(Mutex::new(Vec::new())); + let listener = TestListener { + events: events.clone(), + }; + let mut context_manager = + ContextManager::start_with_capacity(2, listener, WindowId::from(0)).unwrap(); + context_manager.add_context(false, 0); + + let first_route = context_manager.contexts[0].current().route_id; + let second_route = context_manager.contexts[1].current().route_id; + for context in &context_manager.contexts { + Handler::set_private_mode( + &mut *context.current().terminal.lock(), + PrivateMode::new(2033), + ); + } + + let take_reports = || { + std::mem::take(&mut *events.lock().unwrap()) + .into_iter() + .filter_map(|event| match event { + RioEvent::PtyWrite(route, text) => Some((route, text)), + _ => None, + }) + .collect::>() + }; + + assert_eq!( + take_reports(), + [ + (first_route, "\x1b[?999;1n".to_string()), + (second_route, "\x1b[?999;2n".to_string()), + ] + ); + + context_manager.set_current(1); + assert_eq!( + take_reports(), + [ + (first_route, "\x1b[?999;2n".to_string()), + (second_route, "\x1b[?999;1n".to_string()), + ] + ); + assert_eq!( + context_manager + .get_by_route_id_any(first_route) + .map(|item| item.val.route_id), + Some(first_route), + "reply routing must find terminals in inactive tabs" + ); + + context_manager.set_window_visibility(false); + assert_eq!(take_reports(), [(second_route, "\x1b[?999;2n".to_string())]); + + // Switching tabs while hidden must not make the new tab visible. + context_manager.set_current(0); + assert!(take_reports().is_empty()); + + context_manager.set_window_visibility(true); + assert_eq!(take_reports(), [(first_route, "\x1b[?999;1n".to_string())]); + } + fn set_tab_title(cm: &mut ContextManager, index: usize, content: &str) { cm.contexts[index].current_mut().title.content = content.to_string(); } diff --git a/frontends/rioterm/src/layout/mod.rs b/frontends/rioterm/src/layout/mod.rs index 44b99b3c48..f233b054a8 100644 --- a/frontends/rioterm/src/layout/mod.rs +++ b/frontends/rioterm/src/layout/mod.rs @@ -1576,6 +1576,13 @@ impl ContextGrid { } } + /// Set the effective visibility of every terminal pane in this tab. + pub fn set_terminal_visibility(&self, visible: bool) { + for item in self.inner.values() { + item.val.terminal.lock().set_visibility(visible); + } + } + /// Drop image overlays for every panel in the grid. Used on tab /// teardown — the panels themselves go away with the /// `ContextManager`; only the kitty graphics state needs an diff --git a/frontends/rioterm/src/router/mod.rs b/frontends/rioterm/src/router/mod.rs index 86ce27f6a8..91817be61d 100644 --- a/frontends/rioterm/src/router/mod.rs +++ b/frontends/rioterm/src/router/mod.rs @@ -628,6 +628,13 @@ impl<'a> RouteWindow<'a> { configure_window(&self.winit_window, config); } + /// Whether the host has no positive knowledge that this window is hidden. + pub fn is_potentially_visible(&self) -> bool { + !self.is_occluded + && self.winit_window.is_visible() != Some(false) + && self.winit_window.is_minimized() != Some(true) + } + pub fn wait_until(&self) -> Option { // If we need to render after occlusion, render immediately if self.needs_render_after_occlusion { diff --git a/rio-vt/src/ansi/mode.rs b/rio-vt/src/ansi/mode.rs index e79f3f42e5..5ea82fcf59 100644 --- a/rio-vt/src/ansi/mode.rs +++ b/rio-vt/src/ansi/mode.rs @@ -69,6 +69,7 @@ impl PrivateMode { 1049 => Self::Named(NamedPrivateMode::SwapScreenAndSetRestoreCursor), 2004 => Self::Named(NamedPrivateMode::BracketedPaste), 2026 => Self::Named(NamedPrivateMode::SyncUpdate), + 2033 => Self::Named(NamedPrivateMode::VisibilityReports), _ => Self::Unknown(mode), } } @@ -120,6 +121,7 @@ pub enum NamedPrivateMode { BracketedPaste = 2004, /// The mode is handled automatically by [`Processor`]. SyncUpdate = 2026, + VisibilityReports = 2033, } /// Mode for clearing line. diff --git a/rio-vt/src/crosswords/mod.rs b/rio-vt/src/crosswords/mod.rs index 9b4cd4d198..d143ecffac 100644 --- a/rio-vt/src/crosswords/mod.rs +++ b/rio-vt/src/crosswords/mod.rs @@ -97,6 +97,7 @@ bitflags! { const REPORT_ALTERNATE_KEYS = 1 << 20; const REPORT_ALL_KEYS_AS_ESC = 1 << 21; const REPORT_ASSOCIATED_TEXT = 1 << 22; + const VISIBILITY_REPORTS = 1 << 23; const MOUSE_MODE = Self::MOUSE_REPORT_CLICK.bits() | Self::MOUSE_MOTION.bits() | Self::MOUSE_DRAG.bits(); const KITTY_KEYBOARD_PROTOCOL = Self::DISAMBIGUATE_ESC_CODES.bits() | Self::REPORT_EVENT_TYPES.bits() @@ -450,6 +451,7 @@ where pub cursor_shape: CursorShape, pub default_cursor_shape: CursorShape, pub blinking_cursor: bool, + is_potentially_visible: bool, pub window_id: WindowId, pub route_id: usize, title_stack: Vec, @@ -512,6 +514,7 @@ impl Crosswords { default_cursor_shape: cursor_shape, cursor_shape, blinking_cursor: false, + is_potentially_visible: true, window_id, route_id, title_stack: Default::default(), @@ -1823,6 +1826,30 @@ impl Crosswords { self.mode } + /// Update whether this terminal view might be observable. + /// + /// Visibility is conservative: callers must pass `true` whenever the + /// state is unknown. A report is emitted when the state changes while + /// visibility reporting mode is enabled. + pub fn set_visibility(&mut self, is_potentially_visible: bool) { + if self.is_potentially_visible == is_potentially_visible { + return; + } + + self.is_potentially_visible = is_potentially_visible; + if self.mode.contains(Mode::VISIBILITY_REPORTS) { + self.send_visibility_report(); + } + } + + fn send_visibility_report(&self) { + let state = if self.is_potentially_visible { 1 } else { 2 }; + self.event_proxy.send_event( + RioEvent::PtyWrite(self.route_id, format!("\x1b[?999;{state}n")), + self.window_id, + ); + } + #[inline] pub fn cursor(&self) -> CursorState { let mut content = self.cursor_shape; @@ -2309,6 +2336,10 @@ impl Handler for Crosswords { .send_event(RioEvent::CursorBlinkingChange, self.window_id); } NamedPrivateMode::SyncUpdate => (), + NamedPrivateMode::VisibilityReports => { + self.mode.insert(Mode::VISIBILITY_REPORTS); + self.send_visibility_report(); + } } } @@ -2381,6 +2412,9 @@ impl Handler for Crosswords { .send_event(RioEvent::CursorBlinkingChange, self.window_id); } NamedPrivateMode::SyncUpdate => (), + NamedPrivateMode::VisibilityReports => { + self.mode.remove(Mode::VISIBILITY_REPORTS) + } } } @@ -2427,6 +2461,9 @@ impl Handler for Crosswords { self.mode.contains(Mode::BRACKETED_PASTE).into() } NamedPrivateMode::SyncUpdate => ModeState::Reset, + NamedPrivateMode::VisibilityReports => { + self.mode.contains(Mode::VISIBILITY_REPORTS).into() + } NamedPrivateMode::ColumnMode => ModeState::NotSupported, }, PrivateMode::Unknown(_) => ModeState::NotSupported, @@ -3349,6 +3386,11 @@ impl Handler for Crosswords { }; } + #[inline] + fn report_visibility(&mut self) { + self.send_visibility_report(); + } + #[inline] fn newline(&mut self) { self.linefeed(); @@ -6735,6 +6777,79 @@ mod tests { } } + #[test] + fn visibility_reporting_protocol() { + use std::cell::RefCell; + use std::rc::Rc; + + #[derive(Clone)] + struct TestListener { + events: Rc>>, + } + + impl EventListener for TestListener { + fn event(&self) -> (Option, bool) { + (None, false) + } + + fn send_event(&self, event: RioEvent, _id: WindowId) { + self.events.borrow_mut().push(event); + } + } + + let events = Rc::new(RefCell::new(Vec::new())); + let mut term = Crosswords::new( + CrosswordsSize::new(10, 10), + CursorShape::Block, + TestListener { + events: events.clone(), + }, + WindowId::from(0), + 7, + 10, + ); + let mut processor = crate::performer::handler::Processor::default(); + + // Detect support, then enable twice. Every enable must immediately + // report the current state, even when the mode was already enabled. + processor.advance(&mut term, b"\x1b[?2033$p"); + processor.advance(&mut term, b"\x1b[?2033h\x1b[?2033h"); + processor.advance(&mut term, b"\x1b[?2033$p"); + + term.set_visibility(false); + term.set_visibility(false); + processor.advance(&mut term, b"\x1b[?998n"); + + // Disabling suppresses change notifications, but not one-shot queries. + processor.advance(&mut term, b"\x1b[?2033l"); + term.set_visibility(true); + processor.advance(&mut term, b"\x1b[?998n"); + + let reports: Vec = events + .borrow() + .iter() + .filter_map(|event| match event { + RioEvent::PtyWrite(route_id, text) => { + assert_eq!(*route_id, 7); + Some(text.clone()) + } + _ => None, + }) + .collect(); + assert_eq!( + reports, + [ + "\x1b[?2033;2$y", + "\x1b[?999;1n", + "\x1b[?999;1n", + "\x1b[?2033;1$y", + "\x1b[?999;2n", + "\x1b[?999;2n", + "\x1b[?999;1n", + ] + ); + } + #[test] fn set_title_emits_title_event() { use std::cell::RefCell; diff --git a/rio-vt/src/performer/handler.rs b/rio-vt/src/performer/handler.rs index 74d16c69bf..ab0baaa941 100644 --- a/rio-vt/src/performer/handler.rs +++ b/rio-vt/src/performer/handler.rs @@ -157,6 +157,9 @@ pub trait Handler { /// Report device status. fn device_status(&mut self, _: usize) {} + /// Report whether the terminal view is potentially visible. + fn report_visibility(&mut self) {} + /// Move cursor forward `cols`. fn move_forward(&mut self, _: Column) {} @@ -1341,6 +1344,13 @@ impl Perform for Performer<'_, U> { } } ('n', []) => handler.device_status(next_param_or(0) as usize), + ('n', [b'?']) => { + if next_param_or(0) == 998 { + handler.report_visibility(); + } else { + csi_unhandled!(); + } + } ('P', []) => handler.delete_chars(next_param_or(1) as usize), ('p', [b'$']) => { let mode = next_param_or(0); diff --git a/rio-window/src/event.rs b/rio-window/src/event.rs index 288b0c3c87..453fad2f7e 100644 --- a/rio-window/src/event.rs +++ b/rio-window/src/event.rs @@ -464,7 +464,8 @@ pub enum WindowEvent { /// ### Others /// /// - **Web:** Doesn't take into account CSS [`border`], [`padding`], or [`transform`]. - /// - **Android / Wayland / Windows / Orbital:** Unsupported. + /// - **Wayland:** Emitted from the compositor's `xdg_toplevel` suspended state when supported. + /// - **Android / Windows / Orbital:** Unsupported. /// /// [`border`]: https://developer.mozilla.org/en-US/docs/Web/CSS/border /// [`padding`]: https://developer.mozilla.org/en-US/docs/Web/CSS/padding diff --git a/rio-window/src/platform_impl/linux/wayland/state.rs b/rio-window/src/platform_impl/linux/wayland/state.rs index efe380d3ef..22f3890cee 100644 --- a/rio-window/src/platform_impl/linux/wayland/state.rs +++ b/rio-window/src/platform_impl/linux/wayland/state.rs @@ -10,6 +10,7 @@ use sctk::reexports::client::globals::GlobalList; use sctk::reexports::client::protocol::wl_output::WlOutput; use sctk::reexports::client::protocol::wl_surface::WlSurface; use sctk::reexports::client::{Connection, Proxy, QueueHandle}; +use sctk::reexports::csd_frame::WindowState as XdgWindowState; use sctk::compositor::{CompositorHandler, CompositorState}; use sctk::output::{OutputHandler, OutputState}; @@ -23,6 +24,7 @@ use sctk::shm::slot::SlotPool; use sctk::shm::{Shm, ShmHandler}; use sctk::subcompositor::SubcompositorState; +use crate::event::WindowEvent; use crate::platform_impl::wayland::event_loop::sink::EventSink; use crate::platform_impl::wayland::output::MonitorHandle; use crate::platform_impl::wayland::seat::{ @@ -338,14 +340,33 @@ impl WindowHandler for WinitState { }; // Populate the configure to the window. - self.window_compositor_updates[pos].resized |= self + let mut window_state = self .windows .get_mut() .get_mut(&window_id) .expect("got configure for dead window.") .lock() - .unwrap() - .configure(configure, &self.shm, &self.subcompositor_state); + .unwrap(); + let was_suspended = window_state + .last_configure + .as_ref() + .map(|configure| configure.state.contains(XdgWindowState::SUSPENDED)); + + self.window_compositor_updates[pos].resized |= + window_state.configure(configure, &self.shm, &self.subcompositor_state); + + let is_suspended = window_state + .last_configure + .as_ref() + .is_some_and(|configure| configure.state.contains(XdgWindowState::SUSPENDED)); + drop(window_state); + + if was_suspended + .map_or(is_suspended, |was_suspended| was_suspended != is_suspended) + { + self.events_sink + .push_window_event(WindowEvent::Occluded(is_suspended), window_id); + } // NOTE: configure demands wl_surface::commit, however winit doesn't commit on behalf of the // users, since it can break a lot of things, thus it'll ask users to redraw instead. diff --git a/rio-window/src/platform_impl/linux/x11/event_processor.rs b/rio-window/src/platform_impl/linux/x11/event_processor.rs index bc41b63ad1..d697505950 100644 --- a/rio-window/src/platform_impl/linux/x11/event_processor.rs +++ b/rio-window/src/platform_impl/linux/x11/event_processor.rs @@ -871,15 +871,13 @@ impl EventProcessor { wt.update_refresh_loop(WindowId(window as _)); } - fn unmap_notify(&self, xev: &XUnmapEvent, _callback: F) + fn unmap_notify(&self, xev: &XUnmapEvent, mut callback: F) where F: FnMut(&RootAEL, Event), { let window = xev.window as xproto::Window; // Window is unmapped → tear down the per-window vsync timer - // by flipping the mapped bit. We don't fire - // `WindowEvent::Occluded` here — that's reserved for - // `VisibilityNotify` per X11 semantics. We also leave + // and report that it is completely hidden. Leave // `is_fully_obscured` untouched: when the window is mapped // again, the next `VisibilityNotify` will report the real // state (and `update_refresh_loop` keeps the timer torn down @@ -891,6 +889,14 @@ impl EventProcessor { }); let wt = Self::window_target(&self.target); wt.update_refresh_loop(WindowId(window as _)); + + callback( + &self.target, + Event::WindowEvent { + window_id: mkwid(window), + event: WindowEvent::Occluded(true), + }, + ); } fn destroy_notify(&self, xev: &XDestroyWindowEvent, mut callback: F) @@ -950,12 +956,8 @@ impl EventProcessor { let xwindow = xev.window as xproto::Window; let fully_obscured = xev.state == xlib::VisibilityFullyObscured; - let event = Event::WindowEvent { - window_id: mkwid(xwindow), - event: WindowEvent::Occluded(fully_obscured), - }; - callback(&self.target, event); - + // Publish the new state before invoking the application callback so + // synchronous queries like `Window::is_visible` observe it. self.with_window(xwindow, |window| { window .is_fully_obscured @@ -963,6 +965,12 @@ impl EventProcessor { window.visibility_notify(); }); + let event = Event::WindowEvent { + window_id: mkwid(xwindow), + event: WindowEvent::Occluded(fully_obscured), + }; + callback(&self.target, event); + // Re-evaluate the per-window vsync timer — start it if newly // visible, tear it down if newly fully-obscured. let wt = Self::window_target(&self.target);