mirror of
https://github.com/LGUG2Z/komorebi.git
synced 2026-07-09 14:42:44 +02:00
98122bd9d4
This prevents a race where OS-initiated minimizes prematurely remove windows that should be transiently restored. Problem: a display-change can trigger a a fast reconciliation path (same count), and shortly after Windows may emit a SystemMinimizeStart for affected windows. The minimize handler treated those as user-initiated and removed the window, making later reconciliation unable to restore it. Fix: timestamp display-change notifications and add a display_change_in_progress(period) check to the minimize handler. While that grace period is active the minimize handler skips remove_window(), preserving windows so the reconciliator can restore them.
1287 lines
56 KiB
Rust
1287 lines
56 KiB
Rust
#![deny(clippy::unwrap_used, clippy::expect_used)]
|
|
|
|
use crate::DISPLAY_INDEX_PREFERENCES;
|
|
use crate::DUPLICATE_MONITOR_SERIAL_IDS;
|
|
use crate::Notification;
|
|
use crate::NotificationEvent;
|
|
use crate::WORKSPACE_MATCHING_RULES;
|
|
use crate::WindowManager;
|
|
use crate::WindowsApi;
|
|
use crate::border_manager;
|
|
use crate::config_generation::WorkspaceMatchingRule;
|
|
use crate::core::Rect;
|
|
use crate::monitor;
|
|
use crate::monitor::Monitor;
|
|
use crate::monitor_reconciliator::hidden::Hidden;
|
|
use crate::notify_subscribers;
|
|
use crate::state::State;
|
|
use crossbeam_channel::Receiver;
|
|
use crossbeam_channel::Sender;
|
|
use crossbeam_utils::atomic::AtomicConsume;
|
|
use parking_lot::Mutex;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use std::collections::HashMap;
|
|
use std::sync::Arc;
|
|
use std::sync::OnceLock;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::sync::atomic::AtomicI64;
|
|
use std::sync::atomic::Ordering;
|
|
|
|
pub mod hidden;
|
|
|
|
#[derive(Debug, Copy, Clone, Serialize, Deserialize, PartialEq)]
|
|
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
|
|
#[serde(tag = "type", content = "content")]
|
|
pub enum MonitorNotification {
|
|
ResolutionScalingChanged,
|
|
WorkAreaChanged,
|
|
DisplayConnectionChange,
|
|
EnteringSuspendedState,
|
|
ResumingFromSuspendedState,
|
|
SessionLocked,
|
|
SessionUnlocked,
|
|
}
|
|
|
|
static ACTIVE: AtomicBool = AtomicBool::new(true);
|
|
|
|
/// Timestamp (epoch millis) of the last DisplayConnectionChange notification.
|
|
/// Used to suppress OS-initiated window minimizes during transient display events.
|
|
static LAST_DISPLAY_CHANGE_TIMESTAMP: AtomicI64 = AtomicI64::new(0);
|
|
|
|
static CHANNEL: OnceLock<(Sender<MonitorNotification>, Receiver<MonitorNotification>)> =
|
|
OnceLock::new();
|
|
|
|
static MONITOR_CACHE: OnceLock<Mutex<HashMap<String, Monitor>>> = OnceLock::new();
|
|
|
|
pub fn channel() -> &'static (Sender<MonitorNotification>, Receiver<MonitorNotification>) {
|
|
CHANNEL.get_or_init(|| crossbeam_channel::bounded(20))
|
|
}
|
|
|
|
fn event_tx() -> Sender<MonitorNotification> {
|
|
channel().0.clone()
|
|
}
|
|
|
|
fn event_rx() -> Receiver<MonitorNotification> {
|
|
channel().1.clone()
|
|
}
|
|
|
|
pub fn send_notification(notification: MonitorNotification) {
|
|
if matches!(
|
|
notification,
|
|
MonitorNotification::DisplayConnectionChange
|
|
| MonitorNotification::ResumingFromSuspendedState
|
|
| MonitorNotification::SessionUnlocked
|
|
) {
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as i64;
|
|
LAST_DISPLAY_CHANGE_TIMESTAMP.store(now, Ordering::SeqCst);
|
|
}
|
|
|
|
if event_tx().try_send(notification).is_err() {
|
|
tracing::warn!("channel is full; dropping notification")
|
|
}
|
|
}
|
|
|
|
/// Returns true if a display connection change event was received within the
|
|
/// last `grace_period` duration. This is used by the event processor to avoid
|
|
/// treating OS-initiated minimizes (caused by transient monitor disconnects)
|
|
/// as user-initiated minimizes.
|
|
pub fn display_change_in_progress(grace_period: std::time::Duration) -> bool {
|
|
let last = LAST_DISPLAY_CHANGE_TIMESTAMP.load(Ordering::SeqCst);
|
|
if last == 0 {
|
|
return false;
|
|
}
|
|
let now = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_millis() as i64;
|
|
(now - last) < grace_period.as_millis() as i64
|
|
}
|
|
|
|
pub fn insert_in_monitor_cache(serial_or_device_id: &str, monitor: Monitor) {
|
|
let dip = DISPLAY_INDEX_PREFERENCES.read();
|
|
let mut dip_ids = dip.values();
|
|
let preferred_id = if dip_ids.any(|id| id.eq(&monitor.device_id)) {
|
|
monitor.device_id.clone()
|
|
} else if dip_ids.any(|id| Some(id) == monitor.serial_number_id.as_ref()) {
|
|
monitor.serial_number_id.clone().unwrap_or_default()
|
|
} else {
|
|
serial_or_device_id.to_string()
|
|
};
|
|
let mut monitor_cache = MONITOR_CACHE
|
|
.get_or_init(|| Mutex::new(HashMap::new()))
|
|
.lock();
|
|
|
|
monitor_cache.insert(preferred_id, monitor);
|
|
}
|
|
|
|
pub fn attached_display_devices<F, I>(display_provider: F) -> color_eyre::Result<Vec<Monitor>>
|
|
where
|
|
F: Fn() -> I + Copy,
|
|
I: Iterator<Item = Result<win32_display_data::Device, win32_display_data::Error>>,
|
|
{
|
|
let mut attempts = 0;
|
|
|
|
let (displays, errors) = loop {
|
|
let (displays, errors): (Vec<_>, Vec<_>) = display_provider().partition(Result::is_ok);
|
|
|
|
if errors.is_empty() {
|
|
break (displays, errors);
|
|
}
|
|
|
|
for err in &errors {
|
|
if let Err(e) = err {
|
|
tracing::warn!(
|
|
"enumerating display in reconciliator (attempt {}): {:?}",
|
|
attempts + 1,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
if attempts < 5 {
|
|
attempts += 1;
|
|
std::thread::sleep(std::time::Duration::from_millis(150));
|
|
continue;
|
|
}
|
|
|
|
break (displays, errors);
|
|
};
|
|
|
|
if !errors.is_empty() {
|
|
return Err(color_eyre::eyre::eyre!(
|
|
"could not successfully enumerate all displays"
|
|
));
|
|
}
|
|
|
|
let all_displays = displays.into_iter().map(Result::unwrap).collect::<Vec<_>>();
|
|
|
|
let mut serial_id_map = HashMap::new();
|
|
|
|
for d in &all_displays {
|
|
if let Some(id) = &d.serial_number_id {
|
|
*serial_id_map.entry(id.clone()).or_insert(0) += 1;
|
|
}
|
|
}
|
|
|
|
for d in &all_displays {
|
|
if let Some(id) = &d.serial_number_id
|
|
&& serial_id_map.get(id).copied().unwrap_or_default() > 1
|
|
{
|
|
let mut dupes = DUPLICATE_MONITOR_SERIAL_IDS.write();
|
|
if !dupes.contains(id) {
|
|
(*dupes).push(id.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(all_displays
|
|
.into_iter()
|
|
.map(|mut display| {
|
|
let path = display.device_path;
|
|
|
|
let (device, device_id) = if path.is_empty() {
|
|
(String::from("UNKNOWN"), String::from("UNKNOWN"))
|
|
} else {
|
|
let mut split: Vec<_> = path.split('#').collect();
|
|
split.remove(0);
|
|
split.remove(split.len() - 1);
|
|
let device = split[0].to_string();
|
|
let device_id = split.join("-");
|
|
(device, device_id)
|
|
};
|
|
|
|
let name = display.device_name.trim_start_matches(r"\\.\").to_string();
|
|
let name = name.split('\\').collect::<Vec<_>>()[0].to_string();
|
|
|
|
if let Some(id) = &display.serial_number_id {
|
|
let dupes = DUPLICATE_MONITOR_SERIAL_IDS.read();
|
|
if dupes.contains(id) {
|
|
display.serial_number_id = None;
|
|
}
|
|
}
|
|
|
|
monitor::new(
|
|
display.hmonitor,
|
|
display.size.into(),
|
|
display.work_area_size.into(),
|
|
name,
|
|
device,
|
|
device_id,
|
|
display.serial_number_id,
|
|
)
|
|
})
|
|
.collect::<Vec<_>>())
|
|
}
|
|
|
|
pub fn listen_for_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result<()> {
|
|
#[allow(clippy::expect_used)]
|
|
Hidden::create("komorebi-hidden")?;
|
|
|
|
tracing::info!("created hidden window to listen for monitor-related events");
|
|
|
|
std::thread::spawn(move || {
|
|
loop {
|
|
match handle_notifications(wm.clone(), win32_display_data::connected_displays_all) {
|
|
Ok(()) => {
|
|
tracing::warn!("restarting finished thread");
|
|
}
|
|
Err(error) => {
|
|
if cfg!(debug_assertions) {
|
|
tracing::error!("restarting failed thread: {:?}", error)
|
|
} else {
|
|
tracing::error!("restarting failed thread: {}", error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn handle_notifications<F, I>(
|
|
wm: Arc<Mutex<WindowManager>>,
|
|
display_provider: F,
|
|
) -> color_eyre::Result<()>
|
|
where
|
|
F: Fn() -> I + Copy,
|
|
I: Iterator<Item = Result<win32_display_data::Device, win32_display_data::Error>>,
|
|
{
|
|
tracing::info!("listening");
|
|
|
|
let receiver = event_rx();
|
|
|
|
'receiver: for notification in receiver {
|
|
if !ACTIVE.load_consume()
|
|
&& matches!(
|
|
notification,
|
|
MonitorNotification::ResumingFromSuspendedState
|
|
| MonitorNotification::SessionUnlocked
|
|
)
|
|
{
|
|
tracing::debug!(
|
|
"reactivating reconciliator - system has resumed from suspended state or session has been unlocked"
|
|
);
|
|
|
|
ACTIVE.store(true, Ordering::SeqCst);
|
|
border_manager::send_notification(None);
|
|
}
|
|
|
|
// Keep reference to Arc for potential re-locking
|
|
let wm_arc = Arc::clone(&wm);
|
|
let mut wm = wm.lock();
|
|
|
|
let initial_state = State::from(wm.as_ref());
|
|
|
|
match notification {
|
|
MonitorNotification::EnteringSuspendedState | MonitorNotification::SessionLocked => {
|
|
tracing::debug!(
|
|
"deactivating reconciliator until system resumes from suspended state or session is unlocked"
|
|
);
|
|
ACTIVE.store(false, Ordering::SeqCst);
|
|
}
|
|
MonitorNotification::WorkAreaChanged => {
|
|
tracing::debug!("handling work area changed notification");
|
|
let offset = wm.work_area_offset;
|
|
for monitor in wm.monitors_mut() {
|
|
let mut should_update = false;
|
|
|
|
// Update work areas as necessary
|
|
if let Ok(reference) = WindowsApi::monitor(monitor.id)
|
|
&& reference.work_area_size != monitor.work_area_size
|
|
{
|
|
monitor.work_area_size = Rect {
|
|
left: reference.work_area_size.left,
|
|
top: reference.work_area_size.top,
|
|
right: reference.work_area_size.right,
|
|
bottom: reference.work_area_size.bottom,
|
|
};
|
|
|
|
should_update = true;
|
|
}
|
|
|
|
if should_update {
|
|
tracing::info!("updated work area for {}", monitor.device_id);
|
|
monitor.update_focused_workspace(offset)?;
|
|
border_manager::send_notification(None);
|
|
} else {
|
|
tracing::debug!(
|
|
"work areas match, reconciliation not required for {}",
|
|
monitor.device_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
MonitorNotification::ResolutionScalingChanged => {
|
|
tracing::debug!("handling resolution/scaling changed notification");
|
|
let offset = wm.work_area_offset;
|
|
for monitor in wm.monitors_mut() {
|
|
let mut should_update = false;
|
|
|
|
// Update sizes and work areas as necessary
|
|
if let Ok(reference) = WindowsApi::monitor(monitor.id) {
|
|
if reference.work_area_size != monitor.work_area_size {
|
|
monitor.work_area_size = Rect {
|
|
left: reference.work_area_size.left,
|
|
top: reference.work_area_size.top,
|
|
right: reference.work_area_size.right,
|
|
bottom: reference.work_area_size.bottom,
|
|
};
|
|
|
|
should_update = true;
|
|
}
|
|
|
|
if reference.size != monitor.size {
|
|
monitor.size = Rect {
|
|
left: reference.size.left,
|
|
top: reference.size.top,
|
|
right: reference.size.right,
|
|
bottom: reference.size.bottom,
|
|
};
|
|
|
|
should_update = true;
|
|
}
|
|
}
|
|
|
|
if should_update {
|
|
tracing::info!(
|
|
"updated monitor resolution/scaling for {}",
|
|
monitor.device_id
|
|
);
|
|
|
|
monitor.update_focused_workspace(offset)?;
|
|
border_manager::send_notification(None);
|
|
} else {
|
|
tracing::debug!(
|
|
"resolutions match, reconciliation not required for {}",
|
|
monitor.device_id
|
|
);
|
|
}
|
|
}
|
|
}
|
|
// this is handled above if the reconciliator is paused but we should still check if
|
|
// there were any changes to the connected monitors while the system was
|
|
// suspended/locked.
|
|
MonitorNotification::ResumingFromSuspendedState
|
|
| MonitorNotification::SessionUnlocked
|
|
| MonitorNotification::DisplayConnectionChange => {
|
|
tracing::debug!("handling display connection change notification");
|
|
let mut monitor_cache = MONITOR_CACHE
|
|
.get_or_init(|| Mutex::new(HashMap::new()))
|
|
.lock();
|
|
|
|
let initial_monitor_count = wm.monitors().len();
|
|
|
|
// Get the currently attached display devices
|
|
let attached_devices = attached_display_devices(display_provider)?;
|
|
|
|
// Make sure that in our state any attached displays have the latest Win32 data
|
|
for monitor in wm.monitors_mut() {
|
|
for attached in &attached_devices {
|
|
let serial_number_ids_match = if let (Some(attached_snid), Some(m_snid)) =
|
|
(&attached.serial_number_id, &monitor.serial_number_id)
|
|
{
|
|
attached_snid.eq(m_snid)
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if serial_number_ids_match || attached.device_id.eq(&monitor.device_id) {
|
|
monitor.id = attached.id;
|
|
monitor.device = attached.device.clone();
|
|
monitor.device_id = attached.device_id.clone();
|
|
monitor.serial_number_id = attached.serial_number_id.clone();
|
|
monitor.name = attached.name.clone();
|
|
monitor.size = attached.size;
|
|
monitor.work_area_size = attached.work_area_size;
|
|
}
|
|
}
|
|
}
|
|
|
|
if initial_monitor_count == attached_devices.len() {
|
|
tracing::debug!("monitor counts match, reconciliation not required");
|
|
drop(wm);
|
|
continue 'receiver;
|
|
}
|
|
|
|
if attached_devices.is_empty() {
|
|
tracing::debug!(
|
|
"no devices found, skipping reconciliation to avoid breaking state"
|
|
);
|
|
drop(wm);
|
|
continue 'receiver;
|
|
}
|
|
|
|
// Handle potential monitor removal with verification
|
|
let attached_devices = if initial_monitor_count > attached_devices.len() {
|
|
tracing::info!(
|
|
"potential monitor removal detected ({initial_monitor_count} vs {}), verifying in 3s",
|
|
attached_devices.len()
|
|
);
|
|
|
|
// Release locks before waiting
|
|
drop(wm);
|
|
drop(monitor_cache);
|
|
|
|
// Wait 3 seconds for display state to stabilize
|
|
std::thread::sleep(std::time::Duration::from_secs(3));
|
|
|
|
// Re-query the Win32 display APIs
|
|
let re_queried_devices = match attached_display_devices(display_provider) {
|
|
Ok(devices) => devices,
|
|
Err(e) => {
|
|
tracing::error!("failed to re-query display devices: {}", e);
|
|
continue 'receiver;
|
|
}
|
|
};
|
|
|
|
tracing::debug!(
|
|
"after verification: wm had {} monitors, initial query found {}, re-query found {}",
|
|
initial_monitor_count,
|
|
attached_devices.len(),
|
|
re_queried_devices.len()
|
|
);
|
|
|
|
// If monitors are back, the removal was transient (spurious event)
|
|
// Still try to restore state since windows might have been minimized
|
|
if re_queried_devices.len() >= initial_monitor_count {
|
|
tracing::info!(
|
|
"monitor removal was transient (spurious event), attempting state restoration. Initial: {}, Re-queried: {}",
|
|
initial_monitor_count,
|
|
re_queried_devices.len()
|
|
);
|
|
|
|
// Re-acquire locks for state restoration
|
|
wm = wm_arc.lock();
|
|
|
|
// Update Win32 data for all monitors
|
|
for monitor in wm.monitors_mut() {
|
|
for attached in &re_queried_devices {
|
|
let serial_number_ids_match =
|
|
if let (Some(attached_snid), Some(m_snid)) =
|
|
(&attached.serial_number_id, &monitor.serial_number_id)
|
|
{
|
|
attached_snid.eq(m_snid)
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if serial_number_ids_match
|
|
|| attached.device_id.eq(&monitor.device_id)
|
|
{
|
|
monitor.id = attached.id;
|
|
monitor.device = attached.device.clone();
|
|
monitor.device_id = attached.device_id.clone();
|
|
monitor.serial_number_id = attached.serial_number_id.clone();
|
|
monitor.name = attached.name.clone();
|
|
monitor.size = attached.size;
|
|
monitor.work_area_size = attached.work_area_size;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to restore windows that might have been minimized
|
|
let offset = wm.work_area_offset;
|
|
for monitor in wm.monitors_mut() {
|
|
let focused_workspace_idx = monitor.focused_workspace_idx();
|
|
|
|
for (idx, workspace) in monitor.workspaces_mut().iter_mut().enumerate()
|
|
{
|
|
let is_focused_workspace = idx == focused_workspace_idx;
|
|
|
|
if is_focused_workspace {
|
|
// Restore containers
|
|
for container in workspace.containers_mut() {
|
|
if let Some(window) = container.focused_window()
|
|
&& WindowsApi::is_window(window.hwnd)
|
|
{
|
|
tracing::debug!(
|
|
"restoring window after transient removal: {}",
|
|
window.hwnd
|
|
);
|
|
WindowsApi::restore_window(window.hwnd);
|
|
} else if let Some(window) = container.focused_window() {
|
|
tracing::debug!(
|
|
"skipping restore of invalid window: {}",
|
|
window.hwnd
|
|
);
|
|
}
|
|
}
|
|
|
|
// Restore maximized window
|
|
if let Some(window) = &workspace.maximized_window
|
|
&& WindowsApi::is_window(window.hwnd)
|
|
{
|
|
WindowsApi::restore_window(window.hwnd);
|
|
}
|
|
|
|
// Restore monocle container
|
|
if let Some(container) = &workspace.monocle_container
|
|
&& let Some(window) = container.focused_window()
|
|
&& WindowsApi::is_window(window.hwnd)
|
|
{
|
|
WindowsApi::restore_window(window.hwnd);
|
|
}
|
|
|
|
// Restore floating windows
|
|
for window in workspace.floating_windows() {
|
|
if WindowsApi::is_window(window.hwnd) {
|
|
WindowsApi::restore_window(window.hwnd);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
monitor.update_focused_workspace(offset)?;
|
|
}
|
|
|
|
border_manager::send_notification(None);
|
|
continue 'receiver;
|
|
}
|
|
|
|
// If monitors are still missing, proceed with actual removal logic
|
|
tracing::info!(
|
|
"verified monitor removal ({initial_monitor_count} vs {}), removing disconnected monitors",
|
|
re_queried_devices.len()
|
|
);
|
|
|
|
// Re-acquire locks for removal processing
|
|
wm = wm_arc.lock();
|
|
monitor_cache = MONITOR_CACHE
|
|
.get_or_init(|| Mutex::new(HashMap::new()))
|
|
.lock();
|
|
|
|
// Make sure that in our state any attached displays have the latest Win32 data
|
|
// We must do this again because we dropped the lock and are working with new data
|
|
for monitor in wm.monitors_mut() {
|
|
for attached in &re_queried_devices {
|
|
let serial_number_ids_match =
|
|
if let (Some(attached_snid), Some(m_snid)) =
|
|
(&attached.serial_number_id, &monitor.serial_number_id)
|
|
{
|
|
attached_snid.eq(m_snid)
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if serial_number_ids_match || attached.device_id.eq(&monitor.device_id)
|
|
{
|
|
monitor.id = attached.id;
|
|
monitor.device = attached.device.clone();
|
|
monitor.device_id = attached.device_id.clone();
|
|
monitor.serial_number_id = attached.serial_number_id.clone();
|
|
monitor.name = attached.name.clone();
|
|
monitor.size = attached.size;
|
|
monitor.work_area_size = attached.work_area_size;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Use re-queried devices for remaining logic
|
|
re_queried_devices
|
|
} else {
|
|
attached_devices
|
|
};
|
|
|
|
if initial_monitor_count > attached_devices.len() {
|
|
tracing::info!("removing disconnected monitors");
|
|
|
|
// Windows to remove from `known_hwnds`
|
|
let mut windows_to_remove = Vec::new();
|
|
|
|
// Collect the ids in our state which aren't in the current attached display ids
|
|
// These are monitors that have been removed
|
|
let mut newly_removed_displays = vec![];
|
|
|
|
for (m_idx, m) in wm.monitors().iter().enumerate() {
|
|
if !attached_devices.iter().any(|attached| {
|
|
attached.serial_number_id.eq(&m.serial_number_id)
|
|
|| attached.device_id.eq(&m.device_id)
|
|
}) {
|
|
let id = m
|
|
.serial_number_id
|
|
.as_ref()
|
|
.map_or(m.device_id.clone(), |sn| sn.clone());
|
|
|
|
newly_removed_displays.push(id.clone());
|
|
|
|
let focused_workspace_idx = m.focused_workspace_idx();
|
|
|
|
for (idx, workspace) in m.workspaces().iter().enumerate() {
|
|
let is_focused_workspace = idx == focused_workspace_idx;
|
|
let focused_container_idx = workspace.focused_container_idx();
|
|
for (c_idx, container) in workspace.containers().iter().enumerate()
|
|
{
|
|
let focused_window_idx = container.focused_window_idx();
|
|
for (w_idx, window) in container.windows().iter().enumerate() {
|
|
windows_to_remove.push(window.hwnd);
|
|
if is_focused_workspace
|
|
&& c_idx == focused_container_idx
|
|
&& w_idx == focused_window_idx
|
|
{
|
|
// Minimize the focused window since Windows might try
|
|
// to move it to another monitor if it was focused.
|
|
if window.is_focused() {
|
|
window.minimize();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(maximized) = &workspace.maximized_window {
|
|
windows_to_remove.push(maximized.hwnd);
|
|
// Minimize the focused window since Windows might try
|
|
// to move it to another monitor if it was focused.
|
|
if maximized.is_focused() {
|
|
maximized.minimize();
|
|
}
|
|
}
|
|
|
|
if let Some(container) = &workspace.monocle_container {
|
|
for window in container.windows() {
|
|
windows_to_remove.push(window.hwnd);
|
|
}
|
|
if let Some(window) = container.focused_window() {
|
|
// Minimize the focused window since Windows might try
|
|
// to move it to another monitor if it was focused.
|
|
if window.is_focused() {
|
|
window.minimize();
|
|
}
|
|
}
|
|
}
|
|
|
|
for window in workspace.floating_windows() {
|
|
windows_to_remove.push(window.hwnd);
|
|
// Minimize the focused window since Windows might try
|
|
// to move it to another monitor if it was focused.
|
|
if window.is_focused() {
|
|
window.minimize();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove any workspace_rules for this specific monitor
|
|
let mut workspace_rules = WORKSPACE_MATCHING_RULES.lock();
|
|
let mut rules_to_remove = Vec::new();
|
|
for (i, rule) in workspace_rules.iter().enumerate().rev() {
|
|
if rule.monitor_index == m_idx {
|
|
rules_to_remove.push(i);
|
|
}
|
|
}
|
|
for i in rules_to_remove {
|
|
workspace_rules.remove(i);
|
|
}
|
|
|
|
// Let's add their state to the cache for later, make sure to use what
|
|
// the user set as preference as the id.
|
|
let dip = DISPLAY_INDEX_PREFERENCES.read();
|
|
let mut dip_ids = dip.values();
|
|
let preferred_id = if dip_ids.any(|id| id.eq(&m.device_id)) {
|
|
m.device_id.clone()
|
|
} else if dip_ids.any(|id| Some(id) == m.serial_number_id.as_ref()) {
|
|
m.serial_number_id.clone().unwrap_or_default()
|
|
} else {
|
|
id
|
|
};
|
|
monitor_cache.insert(preferred_id, m.clone());
|
|
}
|
|
}
|
|
|
|
// Update known_hwnds
|
|
wm.known_hwnds.retain(|i, _| !windows_to_remove.contains(i));
|
|
|
|
if !newly_removed_displays.is_empty() {
|
|
// After we have cached them, remove them from our state
|
|
wm.monitors_mut().retain(|m| {
|
|
!newly_removed_displays.iter().any(|id| {
|
|
m.serial_number_id.as_ref().is_some_and(|sn| sn == id)
|
|
|| m.device_id.eq(id)
|
|
})
|
|
});
|
|
}
|
|
|
|
let post_removal_monitor_count = wm.monitors().len();
|
|
let focused_monitor_idx = wm.focused_monitor_idx();
|
|
if focused_monitor_idx >= post_removal_monitor_count {
|
|
wm.focus_monitor(0)?;
|
|
}
|
|
|
|
let offset = wm.work_area_offset;
|
|
|
|
for monitor in wm.monitors_mut() {
|
|
// If we have lost a monitor, update everything to filter out any jank
|
|
if initial_monitor_count != post_removal_monitor_count {
|
|
monitor.update_focused_workspace(offset)?;
|
|
}
|
|
}
|
|
}
|
|
|
|
let post_removal_monitor_count = wm.monitors().len();
|
|
|
|
// This is the list of device ids after we have removed detached displays. We can
|
|
// keep this with just the device_ids without the serial numbers since this is used
|
|
// only to check which one is the newly added monitor below if there is a new
|
|
// monitor. Everything done after with said new monitor will again consider both
|
|
// serial number and device ids.
|
|
let post_removal_device_ids = wm
|
|
.monitors()
|
|
.iter()
|
|
.map(|m| &m.device_id)
|
|
.cloned()
|
|
.collect::<Vec<_>>();
|
|
|
|
// Check for and add any new monitors that may have been plugged in
|
|
// Monitor and display index preferences get applied in this function
|
|
WindowsApi::load_monitor_information(&mut wm)?;
|
|
|
|
let post_addition_monitor_count = wm.monitors().len();
|
|
|
|
if post_addition_monitor_count > post_removal_monitor_count {
|
|
tracing::info!(
|
|
"monitor count mismatch ({post_removal_monitor_count} vs {post_addition_monitor_count}), adding connected monitors",
|
|
);
|
|
|
|
let known_hwnds = wm.known_hwnds.clone();
|
|
let offset = wm.work_area_offset;
|
|
let mouse_follows_focus = wm.mouse_follows_focus;
|
|
let focused_monitor_idx = wm.focused_monitor_idx();
|
|
let focused_workspace_idx = wm.focused_workspace_idx()?;
|
|
|
|
// Look in the updated state for new monitors
|
|
for (i, m) in wm.monitors_mut().iter_mut().enumerate() {
|
|
let device_id = &m.device_id;
|
|
// We identify a new monitor when we encounter a new device id
|
|
if !post_removal_device_ids.contains(device_id) {
|
|
let mut cache_hit = false;
|
|
let mut cached_id = String::new();
|
|
// Check if that device id exists in the cache for this session
|
|
if let Some((id, cached)) = monitor_cache.get_key_value(device_id).or(m
|
|
.serial_number_id
|
|
.as_ref()
|
|
.and_then(|sn| monitor_cache.get_key_value(sn)))
|
|
{
|
|
cache_hit = true;
|
|
cached_id = id.clone();
|
|
|
|
tracing::info!(
|
|
"found monitor and workspace configuration for {id} in the monitor cache, applying"
|
|
);
|
|
|
|
// If it does, update the cached monitor info with the new one and
|
|
// load the cached monitor removing any window that has since been
|
|
// closed or moved to another workspace
|
|
*m = Monitor {
|
|
// Data that should be the one just read from `win32-display-data`
|
|
id: m.id,
|
|
name: m.name.clone(),
|
|
device: m.device.clone(),
|
|
device_id: m.device_id.clone(),
|
|
serial_number_id: m.serial_number_id.clone(),
|
|
size: m.size,
|
|
work_area_size: m.work_area_size,
|
|
|
|
// The rest should come from the cached monitor
|
|
work_area_offset: cached.work_area_offset,
|
|
window_based_work_area_offset: cached
|
|
.window_based_work_area_offset,
|
|
window_based_work_area_offset_limit: cached
|
|
.window_based_work_area_offset_limit,
|
|
workspaces: cached.workspaces.clone(),
|
|
last_focused_workspace: cached.last_focused_workspace,
|
|
workspace_names: cached.workspace_names.clone(),
|
|
container_padding: cached.container_padding,
|
|
workspace_padding: cached.workspace_padding,
|
|
wallpaper: cached.wallpaper.clone(),
|
|
floating_layer_behaviour: cached.floating_layer_behaviour,
|
|
};
|
|
|
|
let focused_workspace_idx = m.focused_workspace_idx();
|
|
|
|
for (j, workspace) in m.workspaces_mut().iter_mut().enumerate() {
|
|
// If this is the focused workspace we need to show (restore) all
|
|
// windows that were visible since they were probably minimized by
|
|
// Windows.
|
|
let is_focused_workspace = j == focused_workspace_idx;
|
|
let focused_container_idx = workspace.focused_container_idx();
|
|
|
|
let mut empty_containers = Vec::new();
|
|
for (idx, container) in
|
|
workspace.containers_mut().iter_mut().enumerate()
|
|
{
|
|
container.windows_mut().retain(|window| {
|
|
window.exe().is_ok()
|
|
&& !known_hwnds.contains_key(&window.hwnd)
|
|
});
|
|
|
|
if container.windows().is_empty() {
|
|
empty_containers.push(idx);
|
|
}
|
|
|
|
if is_focused_workspace {
|
|
if let Some(window) = container.focused_window()
|
|
&& WindowsApi::is_window(window.hwnd)
|
|
{
|
|
tracing::debug!(
|
|
"restoring window: {}",
|
|
window.hwnd
|
|
);
|
|
WindowsApi::restore_window(window.hwnd);
|
|
} else {
|
|
// If the focused window was moved or removed by
|
|
// the user after the disconnect then focus the
|
|
// first window and show that one
|
|
container.focus_window(0);
|
|
|
|
if let Some(window) = container.focused_window()
|
|
&& WindowsApi::is_window(window.hwnd)
|
|
{
|
|
WindowsApi::restore_window(window.hwnd);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove empty containers
|
|
for empty_idx in empty_containers {
|
|
if empty_idx == focused_container_idx {
|
|
workspace.remove_container(empty_idx);
|
|
} else {
|
|
workspace.remove_container_by_idx(empty_idx);
|
|
}
|
|
}
|
|
|
|
if let Some(window) = &workspace.maximized_window {
|
|
if window.exe().is_err()
|
|
|| known_hwnds.contains_key(&window.hwnd)
|
|
{
|
|
workspace.maximized_window = None;
|
|
} else if is_focused_workspace
|
|
&& WindowsApi::is_window(window.hwnd)
|
|
{
|
|
WindowsApi::restore_window(window.hwnd);
|
|
}
|
|
}
|
|
|
|
if let Some(container) = &mut workspace.monocle_container {
|
|
container.windows_mut().retain(|window| {
|
|
window.exe().is_ok()
|
|
&& !known_hwnds.contains_key(&window.hwnd)
|
|
});
|
|
|
|
if container.windows().is_empty() {
|
|
workspace.monocle_container = None;
|
|
} else if is_focused_workspace {
|
|
if let Some(window) = container.focused_window()
|
|
&& WindowsApi::is_window(window.hwnd)
|
|
{
|
|
WindowsApi::restore_window(window.hwnd);
|
|
} else {
|
|
// If the focused window was moved or removed by
|
|
// the user after the disconnect then focus the
|
|
// first window and show that one
|
|
container.focus_window(0);
|
|
|
|
if let Some(window) = container.focused_window()
|
|
&& WindowsApi::is_window(window.hwnd)
|
|
{
|
|
WindowsApi::restore_window(window.hwnd);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
workspace.floating_windows_mut().retain(|window| {
|
|
window.exe().is_ok()
|
|
&& !known_hwnds.contains_key(&window.hwnd)
|
|
});
|
|
|
|
if is_focused_workspace {
|
|
for window in workspace.floating_windows() {
|
|
if WindowsApi::is_window(window.hwnd) {
|
|
WindowsApi::restore_window(window.hwnd);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply workspace rules
|
|
let mut workspace_matching_rules =
|
|
WORKSPACE_MATCHING_RULES.lock();
|
|
if let Some(rules) = workspace
|
|
.workspace_config
|
|
.as_ref()
|
|
.and_then(|c| c.workspace_rules.as_ref())
|
|
{
|
|
for r in rules {
|
|
workspace_matching_rules.push(WorkspaceMatchingRule {
|
|
monitor_index: i,
|
|
workspace_index: j,
|
|
matching_rule: r.clone(),
|
|
initial_only: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
if let Some(rules) = workspace
|
|
.workspace_config
|
|
.as_ref()
|
|
.and_then(|c| c.initial_workspace_rules.as_ref())
|
|
{
|
|
for r in rules {
|
|
workspace_matching_rules.push(WorkspaceMatchingRule {
|
|
monitor_index: i,
|
|
workspace_index: j,
|
|
matching_rule: r.clone(),
|
|
initial_only: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore windows from new monitor and update the focused
|
|
// workspace
|
|
m.load_focused_workspace(mouse_follows_focus)?;
|
|
m.update_focused_workspace(offset)?;
|
|
}
|
|
|
|
// Entries in the cache should only be used once; remove the entry there was a cache hit
|
|
if cache_hit && !cached_id.is_empty() {
|
|
monitor_cache.remove(&cached_id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refocus the previously focused monitor since the code above might
|
|
// steal the focus away.
|
|
wm.focus_monitor(focused_monitor_idx)?;
|
|
wm.focus_workspace(focused_workspace_idx)?;
|
|
}
|
|
|
|
let final_count = wm.monitors().len();
|
|
|
|
if post_removal_monitor_count != final_count {
|
|
wm.retile_all(true)?;
|
|
// Second retile to fix DPI/resolution related jank
|
|
wm.retile_all(true)?;
|
|
// Border updates to fix DPI/resolution related jank
|
|
border_manager::send_notification(None);
|
|
}
|
|
}
|
|
}
|
|
|
|
notify_subscribers(
|
|
Notification {
|
|
event: NotificationEvent::Monitor(notification),
|
|
state: wm.as_ref().into(),
|
|
},
|
|
initial_state.has_been_modified(&wm),
|
|
)?;
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::window_manager_event::WindowManagerEvent;
|
|
use crossbeam_channel::Sender;
|
|
use crossbeam_channel::bounded;
|
|
use std::path::PathBuf;
|
|
use uuid::Uuid;
|
|
use windows::Win32::Devices::Display::DISPLAYCONFIG_VIDEO_OUTPUT_TECHNOLOGY;
|
|
// NOTE: Using RECT instead of RECT since I get a mismatched type error. Can be updated if
|
|
// needed.
|
|
use windows::Win32::Foundation::RECT;
|
|
|
|
// Creating a Mock Display Provider
|
|
#[derive(Clone)]
|
|
struct MockDevice {
|
|
hmonitor: isize,
|
|
device_path: String,
|
|
device_name: String,
|
|
device_description: String,
|
|
serial_number_id: Option<String>,
|
|
size: RECT,
|
|
work_area_size: RECT,
|
|
device_key: String,
|
|
output_technology: Option<DISPLAYCONFIG_VIDEO_OUTPUT_TECHNOLOGY>,
|
|
}
|
|
|
|
impl From<MockDevice> for win32_display_data::Device {
|
|
fn from(mock: MockDevice) -> Self {
|
|
win32_display_data::Device {
|
|
hmonitor: mock.hmonitor,
|
|
device_path: mock.device_path,
|
|
device_name: mock.device_name,
|
|
device_description: mock.device_description,
|
|
serial_number_id: mock.serial_number_id,
|
|
size: mock.size,
|
|
work_area_size: mock.work_area_size,
|
|
device_key: mock.device_key,
|
|
output_technology: mock.output_technology,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Creating a Window Manager Instance
|
|
struct TestContext {
|
|
socket_path: Option<PathBuf>,
|
|
}
|
|
|
|
impl Drop for TestContext {
|
|
fn drop(&mut self) {
|
|
if let Some(socket_path) = &self.socket_path {
|
|
// Clean up the socket file
|
|
if let Err(e) = std::fs::remove_file(socket_path) {
|
|
tracing::warn!("Failed to remove socket file: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn setup_window_manager() -> (WindowManager, TestContext) {
|
|
let (_sender, receiver): (Sender<WindowManagerEvent>, Receiver<WindowManagerEvent>) =
|
|
bounded(1);
|
|
|
|
// Temporary socket path for testing
|
|
let socket_name = format!("komorebi-test-{}.sock", Uuid::new_v4());
|
|
let socket_path = PathBuf::from(socket_name);
|
|
|
|
// Create a new WindowManager instance
|
|
let wm = match WindowManager::new(receiver, Some(socket_path.clone())) {
|
|
Ok(manager) => manager,
|
|
Err(e) => {
|
|
panic!("Failed to create WindowManager: {e}");
|
|
}
|
|
};
|
|
|
|
(
|
|
wm,
|
|
TestContext {
|
|
socket_path: Some(socket_path),
|
|
},
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_send_notification() {
|
|
// Create a monitor notification
|
|
let notification = MonitorNotification::ResolutionScalingChanged;
|
|
|
|
// Use the send_notification function to send the notification
|
|
send_notification(notification);
|
|
|
|
// Receive the notification from the channel
|
|
let received = event_rx().try_recv();
|
|
|
|
// Check if we received the notification and if it matches what we sent
|
|
match received {
|
|
Ok(notification) => {
|
|
assert_eq!(notification, MonitorNotification::ResolutionScalingChanged);
|
|
}
|
|
Err(e) => panic!("Failed to receive MonitorNotification: {e}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_channel_bounded_capacity() {
|
|
let (_, receiver) = channel();
|
|
|
|
// Fill the channel to its capacity (20 messages)
|
|
for _ in 0..20 {
|
|
send_notification(MonitorNotification::WorkAreaChanged);
|
|
}
|
|
|
|
// Attempt to send another message (should be dropped)
|
|
send_notification(MonitorNotification::ResolutionScalingChanged);
|
|
|
|
// Verify the channel contains only the first 20 messages
|
|
for _ in 0..20 {
|
|
let notification = match receiver.try_recv() {
|
|
Ok(notification) => notification,
|
|
Err(e) => panic!("Failed to receive MonitorNotification: {e}"),
|
|
};
|
|
assert_eq!(
|
|
notification,
|
|
MonitorNotification::WorkAreaChanged,
|
|
"Unexpected notification in the channel"
|
|
);
|
|
}
|
|
|
|
// Verify that no additional messages are in the channel
|
|
assert!(
|
|
receiver.try_recv().is_err(),
|
|
"Channel should be empty after consuming all messages"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_in_monitor_cache() {
|
|
let m = monitor::new(
|
|
0,
|
|
Rect::default(),
|
|
Rect::default(),
|
|
"Test Monitor".to_string(),
|
|
"Test Device".to_string(),
|
|
"Test Device ID".to_string(),
|
|
Some("TestMonitorID".to_string()),
|
|
);
|
|
|
|
// Insert the monitor into the cache
|
|
insert_in_monitor_cache("TestMonitorID", m.clone());
|
|
|
|
// Retrieve the monitor from the cache
|
|
let cache = MONITOR_CACHE
|
|
.get_or_init(|| Mutex::new(HashMap::new()))
|
|
.lock();
|
|
let retrieved_monitor = cache.get("TestMonitorID");
|
|
|
|
// Check that the monitor was inserted correctly and matches the expected value
|
|
assert_eq!(retrieved_monitor, Some(&m));
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_two_monitors_cache() {
|
|
let m1 = monitor::new(
|
|
0,
|
|
Rect::default(),
|
|
Rect::default(),
|
|
"Test Monitor".to_string(),
|
|
"Test Device".to_string(),
|
|
"Test Device ID".to_string(),
|
|
Some("TestMonitorID".to_string()),
|
|
);
|
|
|
|
let m2 = monitor::new(
|
|
0,
|
|
Rect::default(),
|
|
Rect::default(),
|
|
"Test Monitor 2".to_string(),
|
|
"Test Device 2".to_string(),
|
|
"Test Device ID 2".to_string(),
|
|
Some("TestMonitorID2".to_string()),
|
|
);
|
|
|
|
// Insert the first monitor into the cache
|
|
insert_in_monitor_cache("TestMonitorID", m1.clone());
|
|
|
|
// Insert the second monitor into the cache
|
|
insert_in_monitor_cache("TestMonitorID2", m2.clone());
|
|
|
|
// Retrieve the cache to check if the first and second monitors are present
|
|
let cache = MONITOR_CACHE
|
|
.get_or_init(|| Mutex::new(HashMap::new()))
|
|
.lock();
|
|
|
|
// Check if Monitor 1 was found in the cache
|
|
assert_eq!(
|
|
cache.get("TestMonitorID"),
|
|
Some(&m1),
|
|
"Monitor cache should contain monitor 1"
|
|
);
|
|
|
|
// Check if Monitor 2 was found in the cache
|
|
assert_eq!(
|
|
cache.get("TestMonitorID2"),
|
|
Some(&m2),
|
|
"Monitor cache should contain monitor 2"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_listen_for_notifications() {
|
|
// Create a WindowManager instance for testing
|
|
let (wm, _test_context) = setup_window_manager();
|
|
|
|
// Start the notification listener
|
|
let result = listen_for_notifications(Arc::new(Mutex::new(wm)));
|
|
|
|
// Check if the listener started successfully
|
|
assert!(result.is_ok(), "Failed to start notification listener");
|
|
|
|
// Test sending a notification
|
|
send_notification(MonitorNotification::DisplayConnectionChange);
|
|
|
|
// Receive the notification from the channel
|
|
let received = event_rx().try_recv();
|
|
|
|
// Check if we received the notification and if it matches what we sent
|
|
match received {
|
|
Ok(notification) => {
|
|
assert_eq!(notification, MonitorNotification::DisplayConnectionChange);
|
|
}
|
|
Err(e) => panic!("Failed to receive MonitorNotification: {e}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_attached_display_devices() {
|
|
// Define mock display data
|
|
let mock_monitor = MockDevice {
|
|
hmonitor: 1,
|
|
device_path: String::from(
|
|
"\\\\?\\DISPLAY#ABC123#4&123456&0&UID0#{saucepackets-4321-5678-2468-abc123456789}",
|
|
),
|
|
device_name: String::from("\\\\.\\DISPLAY1"),
|
|
device_description: String::from("Display description"),
|
|
serial_number_id: Some(String::from("SaucePackets123")),
|
|
device_key: String::from("Mock Key"),
|
|
size: RECT {
|
|
left: 0,
|
|
top: 0,
|
|
right: 1920,
|
|
bottom: 1080,
|
|
},
|
|
work_area_size: RECT {
|
|
left: 0,
|
|
top: 0,
|
|
right: 1920,
|
|
bottom: 1080,
|
|
},
|
|
output_technology: Some(DISPLAYCONFIG_VIDEO_OUTPUT_TECHNOLOGY(0)),
|
|
};
|
|
|
|
// Create a closure to simulate the display provider
|
|
let display_provider = || {
|
|
vec![Ok::<win32_display_data::Device, win32_display_data::Error>(
|
|
win32_display_data::Device::from(mock_monitor.clone()),
|
|
)]
|
|
.into_iter()
|
|
};
|
|
|
|
// Should contain the mock monitor
|
|
let result = attached_display_devices(display_provider).ok();
|
|
if let Some(monitors) = result {
|
|
// Check Number of monitors
|
|
assert_eq!(monitors.len(), 1, "Expected one monitor");
|
|
|
|
// hmonitor
|
|
assert_eq!(monitors[0].id, 1);
|
|
|
|
// device name
|
|
assert_eq!(monitors[0].name, String::from("DISPLAY1"));
|
|
|
|
// Device
|
|
assert_eq!(monitors[0].device, String::from("ABC123"));
|
|
|
|
// Device ID
|
|
assert_eq!(
|
|
monitors[0].device_id,
|
|
String::from("ABC123-4&123456&0&UID0")
|
|
);
|
|
|
|
// Check monitor serial number id
|
|
assert_eq!(
|
|
monitors[0].serial_number_id,
|
|
Some(String::from("SaucePackets123")),
|
|
);
|
|
} else {
|
|
panic!("No monitors found");
|
|
}
|
|
}
|
|
}
|