mirage-engine 0.1.0

Mirage, an immediate-mode 3D engine for simple games on desktop and the browser
Documentation
use core::time::Duration;
use std::future::Future;
use std::path::PathBuf;

use winit::application::ApplicationHandler;
use winit::dpi::LogicalSize;
use winit::event_loop::EventLoop;
use winit::window::{CursorGrabMode, Window, WindowAttributes};

use crate::platform::Platform;
use crate::{Config, Error};

pub use std::time::Instant;

/// What this build runs behind: a window of the desktop's own.
pub(crate) const PLATFORM: Platform = Platform::Desktop;

/// Reads the engine's log channel to the standard error stream, at `info`
/// unless `RUST_LOG` sets another level, so what the engine warns about
/// is shown without the game writing a line; one the game set first is
/// kept. `calloop` warns every frame about an event for a source `winit`
/// already dropped (upstream, `bevy#14904`), so its records are held to
/// errors here, in the seam that owns the `winit` loop.
pub(crate) fn install_diagnostics() {
    let _ = env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info"))
        .filter_module("calloop", log::LevelFilter::Error)
        .try_init();
}

/// Blocks until `future` is done — platform code, where blocking belongs.
pub(crate) fn spawn(future: impl Future<Output = ()> + 'static) {
    pollster::block_on(future);
}

/// Reads one asset source relative to the working directory, one of the
/// engine's synchronous reads, all of which live in this file. The
/// browser's own read is not synchronous.
pub(crate) async fn read(source: &str) -> Result<Vec<u8>, Error> {
    std::fs::read(source)
        .map_err(|error| Error::msg(format!("the asset source `{source}` did not open: {error}")))
}

pub(crate) fn window_attributes(config: &Config) -> Result<WindowAttributes, Error> {
    let size = config.size();
    Ok(Window::default_attributes()
        .with_title(config.title())
        .with_inner_size(LogicalSize::new(size.x, size.y)))
}

pub(crate) fn run_app(event_loop: EventLoop<()>, mut app: impl ApplicationHandler + 'static) {
    if let Err(error) = event_loop.run_app(&mut app) {
        report_fatal(&Error::msg(format!("the event loop stopped: {error}")));
    }
}

pub(crate) fn report_fatal(error: &Error) {
    eprintln!("mirage-engine: {error}");
}

/// Nothing to do: the window closes with the loop.
pub(crate) fn close(_window: &Window, _config: &Config) {}

/// The hold a window has on the pointer: what the last frame set, and
/// whether the desktop took it.
pub(crate) struct PointerHold {
    set: bool,
    held: bool,
}

impl PointerHold {
    /// The hold a run starts with, which holds nothing.
    pub(crate) fn released() -> Self {
        Self {
            set: false,
            held: false,
        }
    }

    /// Holds `window`'s pointer in place where `held`, releases it where
    /// not, and reports whether it is held now.
    ///
    /// A desktop that takes the lock keeps the pointer in one place;
    /// `X11`, which has no lock, keeps it confined to the window instead.
    /// One that takes neither holds nothing, and the pointer keeps moving
    /// as it did.
    pub(crate) fn set(&mut self, window: &Window, held: bool) -> bool {
        if core::mem::replace(&mut self.set, held) == held {
            return self.held;
        }

        self.held = held
            && (grab(window, CursorGrabMode::Locked) || grab(window, CursorGrabMode::Confined));
        if !self.held {
            grab(window, CursorGrabMode::None);
        }
        self.held
    }

    /// Nothing to do: a desktop holds the pointer the frame a game sets
    /// the hold, with no gesture of the player's first.
    pub(crate) fn see_gesture(&mut self, _window: &Window) {}

    /// Drops the hold, which no window keeps past its focus: a desktop
    /// takes the hold back as the window loses focus. The first frame to
    /// set a hold after either edge of the focus takes it again.
    pub(crate) fn see_focus_change(&mut self) {
        self.set = false;
        self.held = false;
    }
}

/// Whether `window` took `mode` on its pointer; a desktop that has no such
/// hold states so in a debug log.
fn grab(window: &Window, mode: CursorGrabMode) -> bool {
    window
        .set_cursor_grab(mode)
        .inspect_err(|error| log::debug!("mirage-engine left the pointer as it was: {error}"))
        .is_ok()
}

/// Text the store called `name` kept, a synchronous read of the kind
/// this file alone may make. The browser keeps its own.
pub(crate) fn store_read(name: &str) -> Option<String> {
    read_text(store_path(name)?)
}

/// The text of `path`, or `None` where it did not open, with a debug log
/// of what stopped it.
fn read_text(path: PathBuf) -> Option<String> {
    std::fs::read_to_string(&path)
        .inspect_err(|error| {
            log::debug!(
                "mirage-engine read nothing from {}: {error}",
                path.display()
            );
        })
        .ok()
}

pub(crate) fn store_write(name: &str, text: &str) {
    let Some(path) = store_path(name) else {
        return;
    };
    let Some(folder) = path.parent() else {
        return;
    };
    if let Err(error) = std::fs::create_dir_all(folder).and_then(|()| std::fs::write(&path, text)) {
        log::debug!("mirage-engine kept nothing in {}: {error}", path.display());
    }
}

/// How long after a press a second one still counts as a double click, as
/// this desktop states it, or `None` where none of the files below states
/// one.
///
/// Read once at startup, in this order: `gtk-double-click-time` from
/// `gtk-4.0/settings.ini` and then `gtk-3.0/settings.ini`,
/// `DoubleClickInterval` under `[KDE]` in `kdeglobals`, each under the
/// settings directory, and last the `multiClickTime` line of
/// `~/.Xresources`.
#[cfg(target_os = "linux")]
pub(crate) fn double_click_interval() -> Option<Duration> {
    let settings = |file: &str| settings_dir().map(|dir| dir.join(file)).and_then(read_text);
    let gtk = |version: &str| settings(&format!("{version}/settings.ini"));
    let kde = || settings("kdeglobals");
    let x = || {
        home()
            .map(|home| home.join(".Xresources"))
            .and_then(read_text)
    };

    gtk("gtk-4.0")
        .and_then(|text| Settings(&text).gtk())
        .or_else(|| gtk("gtk-3.0").and_then(|text| Settings(&text).gtk()))
        .or_else(|| kde().and_then(|text| Settings(&text).kde()))
        .or_else(|| x().and_then(|text| Settings(&text).xresources()))
}

/// How long after a press a second one still counts as a double click,
/// which this desktop states through `GetDoubleClickTime`.
#[cfg(windows)]
pub(crate) fn double_click_interval() -> Option<Duration> {
    // SAFETY: the call takes nothing, reads one setting of this program's
    // own and returns a plain number, so it is sound at any time.
    let milliseconds =
        unsafe { windows_sys::Win32::UI::Input::KeyboardAndMouse::GetDoubleClickTime() };

    Some(Duration::from_millis(u64::from(milliseconds)))
}

/// `None`: no `winit` API reaches what this desktop states, so a run on it
/// counts by the engine's own interval.
#[cfg(not(any(target_os = "linux", windows)))]
pub(crate) fn double_click_interval() -> Option<Duration> {
    None
}

/// What a settings file holds, and the double click interval each kind of
/// file states in it.
#[cfg(target_os = "linux")]
struct Settings<'a>(&'a str);

#[cfg(target_os = "linux")]
impl Settings<'_> {
    /// What GTK's `gtk-double-click-time` states, in milliseconds.
    fn gtk(&self) -> Option<Duration> {
        self.keyed("gtk-double-click-time", None)
    }

    /// What `DoubleClickInterval` states under `[KDE]`, in milliseconds.
    fn kde(&self) -> Option<Duration> {
        self.keyed("DoubleClickInterval", Some("[KDE]"))
    }

    /// What the last `multiClickTime` line states, in milliseconds, spelled
    /// either way a machine spells it; the last is the one `xrdb` keeps.
    fn xresources(&self) -> Option<Duration> {
        self.0
            .lines()
            .filter_map(|line| line.split_once(':'))
            .rfind(|(name, _)| matches!(name.trim(), "*.multiClickTime" | "*multiClickTime"))
            .and_then(|(_, value)| Self::milliseconds(value))
    }

    /// What `key` states in milliseconds, under `section` where one is
    /// named and anywhere in the file where none is.
    fn keyed(&self, key: &str, section: Option<&str>) -> Option<Duration> {
        let mut inside = section.is_none();

        for line in self.0.lines().map(str::trim) {
            if line.starts_with('[') {
                inside = section.is_none_or(|named| named == line);
            } else if inside
                && let Some((name, value)) = line.split_once('=')
                && name.trim() == key
            {
                return Self::milliseconds(value);
            }
        }

        None
    }

    /// `text` as a length in milliseconds, or `None` where it states no
    /// whole number of them.
    fn milliseconds(text: &str) -> Option<Duration> {
        text.trim().parse().ok().map(Duration::from_millis)
    }
}

/// The file one kind of a game's kept text is written to, under wherever this
/// desktop keeps a program's settings.
fn store_path(name: &str) -> Option<PathBuf> {
    Some(
        settings_dir()?
            .join("mirage-engine")
            .join(format!("{name}.txt")),
    )
}

#[cfg(target_os = "windows")]
fn settings_dir() -> Option<PathBuf> {
    std::env::var_os("APPDATA").map(PathBuf::from)
}

#[cfg(target_os = "macos")]
fn settings_dir() -> Option<PathBuf> {
    home().map(|home| home.join("Library").join("Application Support"))
}

#[cfg(not(any(target_os = "windows", target_os = "macos")))]
fn settings_dir() -> Option<PathBuf> {
    std::env::var_os("XDG_CONFIG_HOME")
        .map(PathBuf::from)
        .filter(|path| path.is_absolute())
        .or_else(|| home().map(|home| home.join(".config")))
}

#[cfg(not(target_os = "windows"))]
fn home() -> Option<PathBuf> {
    std::env::var_os("HOME").map(PathBuf::from)
}

#[cfg(all(test, target_os = "linux"))]
mod tests {
    use super::*;

    #[test]
    fn each_kind_of_settings_file_states_its_own_interval() {
        assert_eq!(
            Settings("[Settings]\ngtk-double-click-time=250\n").gtk(),
            Some(Duration::from_millis(250))
        );
        assert_eq!(
            Settings("[General]\nDoubleClickInterval=999\n[KDE]\nDoubleClickInterval = 250\n")
                .kde(),
            Some(Duration::from_millis(250)),
            "under its own section, never another's"
        );
        assert_eq!(
            Settings("! what a machine is set to\n*.multiClickTime: 250\n").xresources(),
            Some(Duration::from_millis(250))
        );
        assert_eq!(
            Settings("*multiClickTime: 250\n").xresources(),
            Some(Duration::from_millis(250)),
            "spelled either way"
        );
        assert_eq!(
            Settings("*.multiClickTime: 250\n*.multiClickTime: 300\n").xresources(),
            Some(Duration::from_millis(300)),
            "and the last line is the one that counts"
        );
    }

    #[test]
    fn a_settings_file_stating_no_interval_states_nothing() {
        assert_eq!(Settings("").gtk(), None, "nothing at all");
        assert_eq!(
            Settings("[Settings]\ngtk-font-name=Sans 10\n").gtk(),
            None,
            "other settings"
        );
        assert_eq!(
            Settings("[General]\nDoubleClickInterval=250\n").kde(),
            None,
            "the key outside its own section"
        );
        assert_eq!(
            Settings("*.multiClickTime: soon\n").xresources(),
            None,
            "and a value that is no whole number of milliseconds"
        );
    }
}