#[cfg(any(target_os = "macos", test))]
use std::sync::atomic::{AtomicBool, Ordering};
#[cfg(any(target_os = "macos", test))]
#[derive(Debug, Default)]
pub(crate) struct DisplayChangeGate {
pending: AtomicBool,
}
#[cfg(any(target_os = "macos", test))]
impl DisplayChangeGate {
pub(crate) const fn new() -> Self {
Self {
pending: AtomicBool::new(false),
}
}
pub(crate) fn record(&self) -> bool {
!self.pending.swap(true, Ordering::AcqRel)
}
#[cfg(any(target_os = "macos", test))]
pub(crate) fn clear(&self) {
self.pending.store(false, Ordering::Release);
}
#[cfg(test)]
pub(crate) fn is_pending(&self) -> bool {
self.pending.load(Ordering::Acquire)
}
}
#[derive(Debug, Default)]
pub(crate) struct WindowMoveGate<M> {
last: Option<M>,
}
impl<M: PartialEq> WindowMoveGate<M> {
pub(crate) const fn new() -> Self {
Self { last: None }
}
pub(crate) fn observe(&mut self, current: Option<M>) -> bool {
let Some(current) = current else {
return false;
};
match &self.last {
Some(last) if *last == current => false,
_ => {
let changed = self.last.is_some();
self.last = Some(current);
changed
}
}
}
}
#[cfg(target_os = "macos")]
static DISPLAY_CHANGE_GATE: DisplayChangeGate = DisplayChangeGate::new();
#[cfg(target_os = "macos")]
pub(crate) fn display_change_gate() -> &'static DisplayChangeGate {
&DISPLAY_CHANGE_GATE
}
#[cfg(target_os = "macos")]
mod macos {
use std::sync::OnceLock;
use objc2::rc::Retained;
use objc2::runtime::NSObjectProtocol;
use objc2::{define_class, extern_methods};
use objc2_app_kit::NSApplicationDidChangeScreenParametersNotification;
use objc2_foundation::{NSNotification, NSNotificationCenter, NSObject};
use winit::event_loop::EventLoopProxy;
use crate::app::AppEvent;
use super::display_change_gate;
static PROXY: OnceLock<EventLoopProxy<AppEvent>> = OnceLock::new();
define_class!(
#[unsafe(super = NSObject)]
struct DisplayChangeObserver;
unsafe impl NSObjectProtocol for DisplayChangeObserver {}
impl DisplayChangeObserver {
#[unsafe(method(displayParametersChanged:))]
fn display_parameters_changed(&self, _notification: &NSNotification) {
if !display_change_gate().record() {
return;
}
log::info!("macOS display configuration changed; dispatching recovery event");
match PROXY.get() {
Some(proxy) => {
if let Err(e) = proxy.send_event(AppEvent::DisplayConfigurationChanged) {
log::warn!("Failed to send display-change event to event loop: {e}");
display_change_gate().clear();
}
}
None => {
display_change_gate().clear();
}
}
}
}
);
impl DisplayChangeObserver {
extern_methods!(
#[unsafe(method(new))]
fn new() -> Retained<Self>;
);
}
pub(crate) struct DisplayChangeObserverHandle {
observer: Option<Retained<DisplayChangeObserver>>,
}
impl Drop for DisplayChangeObserverHandle {
fn drop(&mut self) {
if let Some(observer) = self.observer.take() {
let center = NSNotificationCenter::defaultCenter();
unsafe { center.removeObserver(&observer) };
}
}
}
pub(crate) fn register_display_change_observer(
proxy: EventLoopProxy<AppEvent>,
) -> DisplayChangeObserverHandle {
let _ = PROXY.set(proxy);
let center = NSNotificationCenter::defaultCenter();
let observer = DisplayChangeObserver::new();
unsafe {
center.addObserver_selector_name_object(
&observer,
objc2::sel!(displayParametersChanged:),
Some(NSApplicationDidChangeScreenParametersNotification),
None,
);
}
log::debug!("Registered NSApplicationDidChangeScreenParametersNotification observer");
DisplayChangeObserverHandle {
observer: Some(observer),
}
}
}
#[cfg(target_os = "macos")]
pub(crate) use macos::register_display_change_observer;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_notification_dispatches() {
let gate = DisplayChangeGate::new();
assert!(!gate.is_pending());
assert!(gate.record(), "first notification must dispatch");
assert!(gate.is_pending());
}
#[test]
fn burst_is_coalesced_to_one_dispatch() {
let gate = DisplayChangeGate::new();
assert!(gate.record());
assert!(
!gate.record(),
"second notification in burst must be suppressed"
);
assert!(
!gate.record(),
"third notification in burst must be suppressed"
);
assert!(gate.is_pending());
}
#[test]
fn clear_re_arms_the_gate() {
let gate = DisplayChangeGate::new();
assert!(gate.record());
gate.clear();
assert!(!gate.is_pending());
assert!(
gate.record(),
"after clear, a new change must dispatch again"
);
}
#[test]
fn interleaved_handling_keeps_edge_trigger_semantics() {
let gate = DisplayChangeGate::new();
assert!(gate.record());
assert!(!gate.record());
assert!(!gate.record());
assert!(!gate.record());
gate.clear();
assert!(gate.record());
assert!(!gate.record());
gate.clear();
assert!(!gate.is_pending());
}
#[test]
fn fresh_gate_is_not_pending() {
let gate = DisplayChangeGate::default();
assert!(!gate.is_pending());
}
#[test]
fn move_gate_first_observation_records_without_firing() {
let mut gate = WindowMoveGate::new();
assert!(
!gate.observe(Some("monitor-a")),
"the first Moved event only establishes the baseline"
);
}
#[test]
fn move_gate_same_monitor_never_fires() {
let mut gate = WindowMoveGate::new();
gate.observe(Some("monitor-a"));
for _ in 0..50 {
assert!(
!gate.observe(Some("monitor-a")),
"moving within one monitor must not fire"
);
}
}
#[test]
fn move_gate_fires_once_per_monitor_crossing() {
let mut gate = WindowMoveGate::new();
gate.observe(Some("monitor-a"));
assert!(
gate.observe(Some("monitor-b")),
"crossing to another monitor must fire exactly once"
);
assert!(!gate.observe(Some("monitor-b")));
assert!(gate.observe(Some("monitor-a")));
}
#[test]
fn move_gate_transient_unknown_monitor_keeps_baseline() {
let mut gate = WindowMoveGate::new();
gate.observe(Some("monitor-a"));
assert!(!gate.observe(None));
assert!(!gate.observe(Some("monitor-a")));
assert!(gate.observe(Some("monitor-b")));
}
#[test]
fn move_gate_baseline_from_none_via_none_is_inert() {
let mut gate = WindowMoveGate::new();
assert!(!gate.observe(None));
assert!(!gate.observe(Some("monitor-a")), "still just a baseline");
}
}