bevy_window_manager 0.18.2

Bevy plugin for primary window restoration and multi-monitor support
Documentation
//! Window management plugin for Bevy.
//!
//! This plugin provides multi-monitor support, window state persistence, and utilities
//! for managing window position and size across application sessions.
//!
//! # The Problem
//!
//! On macOS with multiple monitors that have different scale factors (e.g., a Retina display
//! at scale 2.0 and an external monitor at scale 1.0), Bevy's window positioning has issues:
//!
//! 1. **`Window.position` is unreliable at startup**: When a window is created, `Window.position`
//!    is `Automatic` (not `At(pos)`), even though winit has placed the window at a specific
//!    physical position.
//!
//! 2. **Scale factor conversion in `changed_windows`**: When you modify `Window.resolution`, Bevy's
//!    `changed_windows` system applies scale factor conversion if `scale_factor !=
//!    cached_scale_factor`. This corrupts the size when moving windows between monitors with
//!    different scale factors.
//!
//! 3. **Timing of scale factor updates**: The `CachedWindow` is updated after winit events are
//!    processed, but our systems run before we receive the `ScaleFactorChanged` event.
//!
//! # The Solution
//!
//! This plugin uses winit directly to capture the actual window position at startup,
//! compensates for scale factor conversions, and properly restores windows across monitors.
//!
//! # Usage
//!
//! ```no_run
//! use bevy::prelude::*;
//! use bevy_window_manager::WindowManagerPlugin;
//!
//! App::new()
//!     .add_plugins(DefaultPlugins)
//!     .add_plugins(WindowManagerPlugin)
//!     .run();
//! ```
//!
//! The plugin automatically hides the window during startup and shows it after positioning
//! is complete, preventing any visual flash at the default position.
//!
//! See `examples/custom_app_name.rs` for how to override the `app_name` used in the path
//! (default is to choose executable name).
//!
//! See `examples/custom_path.rs` for how to override the full path to the state file.

#[cfg(all(target_os = "macos", feature = "workaround-winit-4441"))]
mod macos_drag_back_fix;
mod monitors;
mod state;
mod systems;
mod types;
mod window_ext;
#[cfg(all(target_os = "windows", feature = "workaround-winit-4341"))]
mod windows_dpi_fix;
#[cfg(all(target_os = "linux", feature = "workaround-winit-4445"))]
mod x11_frame_extents;

use std::path::PathBuf;

use bevy::prelude::*;
use bevy::window::PrimaryWindow;
#[cfg(all(target_os = "macos", feature = "workaround-winit-4441"))]
pub use macos_drag_back_fix::DragBackSizeProtection;
pub use monitors::CurrentMonitor;
pub use monitors::MonitorInfo;
use monitors::MonitorPlugin;
pub use monitors::Monitors;
use monitors::init_monitors;
use types::RestoreWindowConfig;
use types::TargetPosition;
pub use types::WindowTargetLoaded;
use types::X11FrameCompensated;
pub use window_ext::WindowExt;

/// The main plugin. See module docs for usage.
///
/// Default state file locations:
/// - macOS: `~/Library/Application Support/<exe_name>/windows.ron`
/// - Linux: `~/.config/<exe_name>/windows.ron`
/// - Windows: `C:\Users\<User>\AppData\Roaming\<exe_name>\windows.ron`
///
/// Unit struct version for convenience using `.add_plugins(WindowManagerPlugin)`.
pub struct WindowManagerPlugin;

impl WindowManagerPlugin {
    /// Create a plugin with a custom app name.
    ///
    /// Uses `config_dir()/<app_name>/windows.ron`.
    ///
    /// # Panics
    ///
    /// Panics if the config directory cannot be determined.
    #[must_use]
    #[expect(clippy::expect_used, reason = "fail fast if path cannot be determined")]
    pub fn with_app_name(app_name: impl Into<String>) -> impl Plugin {
        WindowManagerPluginCustomPath {
            path: state::get_state_path_for_app(&app_name.into())
                .expect("Could not determine state file path"),
        }
    }

    /// Create a plugin with a custom state file path.
    #[must_use]
    pub fn with_path(path: impl Into<PathBuf>) -> impl Plugin {
        WindowManagerPluginCustomPath { path: path.into() }
    }
}

impl Plugin for WindowManagerPlugin {
    #[expect(clippy::expect_used, reason = "fail fast if path cannot be determined")]
    fn build(&self, app: &mut App) {
        let path = state::get_default_state_path().expect("Could not determine state file path");
        build_plugin(app, path);
    }
}

/// Plugin variant with a custom state file path.
struct WindowManagerPluginCustomPath {
    path: PathBuf,
}

impl Plugin for WindowManagerPluginCustomPath {
    fn build(&self, app: &mut App) { build_plugin(app, self.path.clone()); }
}

/// Hide the primary window when created, before winit creates the OS window.
///
/// Uses an observer on `PrimaryWindow` component addition, so it works regardless
/// of plugin order. The window will be shown after restore completes or immediately
/// if no saved state.
///
/// Note: We observe `Add<PrimaryWindow>` rather than `Add<Window>` because when
/// `Window` is added, `PrimaryWindow` may not exist yet. By observing `PrimaryWindow`,
/// we know the `Window` component already exists on the entity.
fn hide_window_on_creation(add: On<Add, PrimaryWindow>, mut windows: Query<&mut Window>) {
    debug!(
        "[hide_window_on_creation] Observer fired for entity {:?}",
        add.entity
    );
    if let Ok(mut window) = windows.get_mut(add.entity) {
        debug!("[hide_window_on_creation] Setting window.visible = false");
        window.visible = false;
    }
}

/// The run conditions allow us to separate the initial primary window restore from
/// subsequent positions saves - which we dont' want to do until AFTER we've done
/// the initial restore.
fn build_plugin(app: &mut App, path: PathBuf) {
    // Hide primary window to prevent flash at default position.
    // Two cases to handle:
    // 1. Window already exists (WindowManagerPlugin added after DefaultPlugins) - hide immediately
    // 2. Window doesn't exist yet (WindowManagerPlugin added before DefaultPlugins) - use observer
    //
    // EXCEPTION: On Linux X11 with frame extent compensation (workaround-winit-4445),
    // we cannot hide the window because the compensation system needs to query
    // _NET_FRAME_EXTENTS, which requires the window to be visible/mapped.
    #[cfg(all(target_os = "linux", feature = "workaround-winit-4445"))]
    let should_hide = systems::is_wayland();
    #[cfg(not(all(target_os = "linux", feature = "workaround-winit-4445")))]
    let should_hide = true;

    if should_hide {
        let mut query = app
            .world_mut()
            .query_filtered::<&mut Window, With<PrimaryWindow>>();
        if let Some(mut window) = query.iter_mut(app.world_mut()).next() {
            debug!("[build_plugin] Window already exists, hiding immediately");
            window.visible = false;
        } else {
            debug!("[build_plugin] Window doesn't exist yet, registering observer");
            app.add_observer(hide_window_on_creation);
        }
    } else {
        debug!("[build_plugin] Linux X11: skipping window hide for frame extent compensation");
    }

    #[cfg(all(target_os = "macos", feature = "workaround-winit-4441"))]
    macos_drag_back_fix::init(app);

    #[cfg(all(target_os = "windows", feature = "workaround-winit-4341"))]
    windows_dpi_fix::init(app);

    app.add_plugins(MonitorPlugin)
        .insert_resource(RestoreWindowConfig { path })
        .add_systems(
            PreStartup,
            (
                systems::init_winit_info,
                systems::load_target_position,
                systems::move_to_target_monitor.run_if(resource_exists::<TargetPosition>),
            )
                .chain()
                .after(init_monitors),
        );

    // X11 frame extent compensation (Linux + W6 + X11 only)
    // Runs until token exists, then restore_primary_window takes over
    #[cfg(all(target_os = "linux", feature = "workaround-winit-4445"))]
    app.add_systems(
        Update,
        x11_frame_extents::compensate_target_position
            .run_if(resource_exists::<TargetPosition>)
            .run_if(not(resource_exists::<X11FrameCompensated>))
            .run_if(not(systems::is_wayland)),
    );

    // Restore primary window - always gated by token
    // Token is inserted immediately in load_target_position when compensation isn't needed
    app.add_systems(
        Update,
        systems::restore_primary_window
            .run_if(resource_exists::<TargetPosition>)
            .run_if(resource_exists::<X11FrameCompensated>),
    );

    // Linux: includes Wayland monitor detection with ordering constraint
    #[cfg(target_os = "linux")]
    app.add_systems(
        Update,
        (
            systems::update_wayland_monitor.run_if(systems::is_wayland),
            systems::save_window_state
                .run_if(not(resource_exists::<TargetPosition>))
                .after(systems::update_wayland_monitor),
        ),
    );

    // Non-Linux: no Wayland handling needed
    #[cfg(not(target_os = "linux"))]
    app.add_systems(
        Update,
        systems::save_window_state.run_if(not(resource_exists::<TargetPosition>)),
    );
}