Skip to main content

mulciber_platform/
lib.rs

1//! Native window, event, display, and lifecycle support for Mulciber.
2//!
3//! This API is experimental. Its native `AppKit`, Win32, Wayland, and X11 implementations preserve
4//! their backend-specific ownership and lifecycle machinery behind the same game-facing contract.
5
6use core::fmt;
7
8#[cfg(target_os = "macos")]
9mod macos;
10
11#[cfg(target_os = "macos")]
12#[doc(hidden)]
13pub use macos::integration;
14#[cfg(target_os = "macos")]
15pub use macos::{Application, SurfaceTarget, Window};
16
17#[cfg(target_os = "linux")]
18mod linux;
19
20#[cfg(target_os = "linux")]
21#[doc(hidden)]
22pub use linux::integration;
23#[cfg(target_os = "linux")]
24pub use linux::{Application, SurfaceTarget, Window};
25
26#[cfg(target_os = "windows")]
27mod win32;
28
29#[cfg(target_os = "windows")]
30#[doc(hidden)]
31pub use win32::integration;
32#[cfg(target_os = "windows")]
33pub use win32::{Application, SurfaceTarget, Window};
34
35/// A two-dimensional extent in logical window coordinates.
36#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
37pub struct LogicalSize {
38    width: u32,
39    height: u32,
40}
41
42/// A position in logical window coordinates with a top-left origin.
43#[derive(Clone, Copy, Debug, Default, PartialEq)]
44pub struct LogicalPosition {
45    x: f64,
46    y: f64,
47}
48
49impl LogicalPosition {
50    /// Creates a logical position.
51    #[must_use]
52    pub const fn new(x: f64, y: f64) -> Self {
53        Self { x, y }
54    }
55
56    /// Returns the horizontal logical coordinate.
57    #[must_use]
58    pub const fn x(self) -> f64 {
59        self.x
60    }
61
62    /// Returns the vertical logical coordinate.
63    #[must_use]
64    pub const fn y(self) -> f64 {
65        self.y
66    }
67}
68
69impl LogicalSize {
70    /// Creates a logical size from its width and height.
71    #[must_use]
72    pub const fn new(width: u32, height: u32) -> Self {
73        Self { width, height }
74    }
75
76    /// Returns the width in logical window coordinates.
77    #[must_use]
78    pub const fn width(self) -> u32 {
79        self.width
80    }
81
82    /// Returns the height in logical window coordinates.
83    #[must_use]
84    pub const fn height(self) -> u32 {
85        self.height
86    }
87
88    /// Returns whether either dimension is zero.
89    #[must_use]
90    pub const fn is_empty(self) -> bool {
91        self.width == 0 || self.height == 0
92    }
93}
94
95/// A two-dimensional drawable extent in physical pixels.
96#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
97pub struct PhysicalExtent {
98    width: u32,
99    height: u32,
100}
101
102impl PhysicalExtent {
103    /// Creates a physical extent from its pixel width and height.
104    #[must_use]
105    pub const fn new(width: u32, height: u32) -> Self {
106        Self { width, height }
107    }
108
109    /// Returns the width in physical pixels.
110    #[must_use]
111    pub const fn width(self) -> u32 {
112        self.width
113    }
114
115    /// Returns the height in physical pixels.
116    #[must_use]
117    pub const fn height(self) -> u32 {
118        self.height
119    }
120
121    /// Returns whether either dimension is zero.
122    #[must_use]
123    pub const fn is_empty(self) -> bool {
124        self.width == 0 || self.height == 0
125    }
126}
127
128/// Identifies one revision of a window's drawable metrics.
129#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
130pub struct WindowRevision(u64);
131
132impl WindowRevision {
133    #[cfg_attr(
134        not(any(target_os = "macos", target_os = "linux", target_os = "windows")),
135        allow(dead_code)
136    )]
137    pub(crate) const INITIAL: Self = Self(1);
138
139    #[cfg_attr(
140        not(any(target_os = "macos", target_os = "linux", target_os = "windows")),
141        allow(dead_code)
142    )]
143    pub(crate) const fn next(self) -> Self {
144        Self(self.0.saturating_add(1))
145    }
146
147    /// Returns the monotonically increasing revision number.
148    #[must_use]
149    pub const fn get(self) -> u64 {
150        self.0
151    }
152}
153
154/// The current drawable metrics of a window.
155#[derive(Clone, Copy, Debug, PartialEq)]
156pub struct WindowMetrics {
157    extent: PhysicalExtent,
158    scale_factor: f64,
159    revision: WindowRevision,
160}
161
162impl WindowMetrics {
163    #[cfg_attr(
164        not(any(target_os = "macos", target_os = "linux", target_os = "windows")),
165        allow(dead_code)
166    )]
167    pub(crate) const fn new(
168        extent: PhysicalExtent,
169        scale_factor: f64,
170        revision: WindowRevision,
171    ) -> Self {
172        Self {
173            extent,
174            scale_factor,
175            revision,
176        }
177    }
178
179    /// Returns the drawable extent in physical pixels.
180    #[must_use]
181    pub const fn extent(self) -> PhysicalExtent {
182        self.extent
183    }
184
185    /// Returns physical pixels per logical window coordinate.
186    #[must_use]
187    pub const fn scale_factor(self) -> f64 {
188        self.scale_factor
189    }
190
191    /// Returns the revision of these window metrics.
192    #[must_use]
193    pub const fn revision(self) -> WindowRevision {
194        self.revision
195    }
196}
197
198/// Describes a native window before creation.
199#[derive(Clone, Debug, Eq, PartialEq)]
200pub struct WindowDescriptor {
201    title: String,
202    logical_size: LogicalSize,
203}
204
205/// Whether a keyboard key or pointer button was pressed or released.
206#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207pub enum ButtonState {
208    /// The control became pressed.
209    Pressed,
210    /// The control became released.
211    Released,
212}
213
214/// A physical, position-oriented keyboard key.
215///
216/// Text entry and input-method composition are deliberately separate from this gameplay-oriented
217/// identity and are not part of the first input evidence slice.
218#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
219#[non_exhaustive]
220#[allow(missing_docs)]
221pub enum KeyCode {
222    KeyA,
223    KeyB,
224    KeyC,
225    KeyD,
226    KeyE,
227    KeyF,
228    KeyG,
229    KeyH,
230    KeyI,
231    KeyJ,
232    KeyK,
233    KeyL,
234    KeyM,
235    KeyN,
236    KeyO,
237    KeyP,
238    KeyQ,
239    KeyR,
240    KeyS,
241    KeyT,
242    KeyU,
243    KeyV,
244    KeyW,
245    KeyX,
246    KeyY,
247    KeyZ,
248    Digit0,
249    Digit1,
250    Digit2,
251    Digit3,
252    Digit4,
253    Digit5,
254    Digit6,
255    Digit7,
256    Digit8,
257    Digit9,
258    Escape,
259    Space,
260    Enter,
261    Tab,
262    Backspace,
263    Delete,
264    Insert,
265    Home,
266    End,
267    PageUp,
268    PageDown,
269    ArrowLeft,
270    ArrowRight,
271    ArrowUp,
272    ArrowDown,
273    Minus,
274    Equal,
275    BracketLeft,
276    BracketRight,
277    Backslash,
278    Semicolon,
279    Quote,
280    Backquote,
281    Comma,
282    Period,
283    Slash,
284    F1,
285    F2,
286    F3,
287    F4,
288    F5,
289    F6,
290    F7,
291    F8,
292    F9,
293    F10,
294    F11,
295    F12,
296    F13,
297    F14,
298    F15,
299    F16,
300    F17,
301    F18,
302    F19,
303    F20,
304    Numpad0,
305    Numpad1,
306    Numpad2,
307    Numpad3,
308    Numpad4,
309    Numpad5,
310    Numpad6,
311    Numpad7,
312    Numpad8,
313    Numpad9,
314    NumpadDecimal,
315    NumpadMultiply,
316    NumpadAdd,
317    NumpadSubtract,
318    NumpadDivide,
319    NumpadEnter,
320    NumpadEqual,
321    NumpadClear,
322    /// A physical key whose current backend mapping is not yet represented portably.
323    Unidentified(u32),
324}
325
326/// The currently active device-independent keyboard modifiers.
327#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
328pub struct Modifiers(u8);
329
330impl Modifiers {
331    pub(crate) const SHIFT: u8 = 1 << 0;
332    pub(crate) const CONTROL: u8 = 1 << 1;
333    pub(crate) const ALT: u8 = 1 << 2;
334    pub(crate) const SUPER: u8 = 1 << 3;
335    pub(crate) const CAPS_LOCK: u8 = 1 << 4;
336    pub(crate) const FUNCTION: u8 = 1 << 5;
337
338    #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
339    pub(crate) const fn from_bits(bits: u8) -> Self {
340        Self(bits)
341    }
342
343    /// Returns whether either Shift key is active.
344    #[must_use]
345    pub const fn shift(self) -> bool {
346        self.0 & Self::SHIFT != 0
347    }
348
349    /// Returns whether either Control key is active.
350    #[must_use]
351    pub const fn control(self) -> bool {
352        self.0 & Self::CONTROL != 0
353    }
354
355    /// Returns whether either Alt/Option key is active.
356    #[must_use]
357    pub const fn alt(self) -> bool {
358        self.0 & Self::ALT != 0
359    }
360
361    /// Returns whether either platform Command/Super key is active.
362    #[must_use]
363    pub const fn super_key(self) -> bool {
364        self.0 & Self::SUPER != 0
365    }
366
367    /// Returns whether Caps Lock is active.
368    #[must_use]
369    pub const fn caps_lock(self) -> bool {
370        self.0 & Self::CAPS_LOCK != 0
371    }
372
373    /// Returns whether the platform function modifier is active.
374    #[must_use]
375    pub const fn function(self) -> bool {
376        self.0 & Self::FUNCTION != 0
377    }
378}
379
380/// A pointer button identity independent from handedness preferences.
381#[derive(Clone, Copy, Debug, Eq, PartialEq)]
382#[allow(missing_docs)]
383pub enum PointerButton {
384    Primary,
385    Secondary,
386    Middle,
387    Other(u16),
388}
389
390/// How a window interacts with the system pointer.
391///
392/// Capture is an application intent; the platform owns the native cursor-visibility, pinning, and
393/// relative-motion policy behind it, so games do not restate the locked-versus-confined fallback
394/// that every surveyed consumer hand-rolls.
395#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
396pub enum CursorMode {
397    /// The system pointer is visible and moves freely.
398    #[default]
399    Normal,
400    /// While the window is focused, the pointer is hidden, held inside the window, and reported
401    /// as relative [`InputEvent::PointerDelta`] motion instead of absolute positions.
402    Captured,
403}
404
405/// Whether a window shares the desktop as a movable window or occupies its current display.
406///
407/// Fullscreen is an application intent implemented as each platform's borderless or native
408/// fullscreen path on the window's current display; no exclusive display mode is requested. The
409/// window system confirms each transition and can enter or leave fullscreen on its own (user
410/// shortcuts, compositor policy), so the reported mode follows confirmed transitions instead of
411/// restating the last request; a toggle keyed off the reported mode stays correct either way.
412#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
413pub enum WindowMode {
414    /// The window is movable and shares the desktop with other windows.
415    #[default]
416    Windowed,
417    /// The window occupies its display without decorations.
418    Fullscreen,
419}
420
421/// Follows a platform-confirmed fullscreen transition.
422///
423/// Only a change in confirmed state drags the requested mode, so a stale windowed report that
424/// races a fullscreen request still in flight cannot cancel the intent, while transitions the
425/// window system starts on its own (user shortcuts, compositor policy) update the reported mode.
426// Win32 owns its borderless transition synchronously, so only the asynchronous backends use this.
427#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
428pub(crate) fn follow_confirmed_fullscreen(
429    confirmed: &core::cell::Cell<bool>,
430    requested: &core::cell::Cell<bool>,
431    now: bool,
432) {
433    if confirmed.replace(now) != now {
434        requested.set(now);
435    }
436}
437
438/// A scroll delta preserving whether the platform supplied precise or coarse units.
439#[derive(Clone, Copy, Debug, PartialEq)]
440#[allow(missing_docs)]
441pub enum ScrollDelta {
442    /// Precise logical-coordinate deltas, typically from a trackpad.
443    Precise { x: f64, y: f64 },
444    /// Coarse wheel-step deltas.
445    Coarse { x: f64, y: f64 },
446}
447
448/// One ordered gameplay-oriented input transition from the native event queue.
449#[derive(Clone, Copy, Debug, PartialEq)]
450#[non_exhaustive]
451#[allow(missing_docs)]
452pub enum InputEvent {
453    /// The window gained or lost keyboard focus.
454    FocusChanged { focused: bool },
455    /// A physical key changed state.
456    Keyboard {
457        key: KeyCode,
458        state: ButtonState,
459        repeat: bool,
460        modifiers: Modifiers,
461    },
462    /// The aggregate modifier state changed.
463    ModifiersChanged(Modifiers),
464    /// The pointer moved in logical client coordinates.
465    PointerMoved {
466        position: LogicalPosition,
467        modifiers: Modifiers,
468    },
469    /// The pointer moved by a relative delta while captured, in logical coordinates with
470    /// positive y pointing down. Delivered instead of [`Self::PointerMoved`] during capture.
471    PointerDelta {
472        delta_x: f64,
473        delta_y: f64,
474        modifiers: Modifiers,
475    },
476    /// A pointer button changed state.
477    PointerButton {
478        button: PointerButton,
479        state: ButtonState,
480        position: LogicalPosition,
481        modifiers: Modifiers,
482    },
483    /// A wheel or trackpad supplied a scroll delta at the pointer position.
484    Scroll {
485        delta: ScrollDelta,
486        position: LogicalPosition,
487        modifiers: Modifiers,
488    },
489}
490
491impl WindowDescriptor {
492    /// Creates a window descriptor with a logical client extent.
493    pub fn new(title: impl Into<String>, logical_size: LogicalSize) -> Self {
494        Self {
495            title: title.into(),
496            logical_size,
497        }
498    }
499
500    /// Returns the requested title.
501    #[must_use]
502    pub fn title(&self) -> &str {
503        &self.title
504    }
505
506    /// Returns the requested logical client extent.
507    #[must_use]
508    pub const fn logical_size(&self) -> LogicalSize {
509        self.logical_size
510    }
511}
512
513/// A window lifecycle event delivered while pumping the native event queue.
514#[derive(Clone, Copy, Debug, PartialEq)]
515#[non_exhaustive]
516pub enum WindowEvent {
517    /// The drawable extent or backing scale changed.
518    MetricsChanged(WindowMetrics),
519    /// Rendering should pause, such as while the window is minimized or fully occluded.
520    RenderingSuspended,
521    /// A paused window became drawable again.
522    RenderingResumed(WindowMetrics),
523    /// The window can render a frame with the supplied current metrics.
524    RedrawRequested(WindowMetrics),
525    /// Native window closure requested termination of this window's loop.
526    CloseRequested,
527    /// An ordered input transition associated with this window.
528    Input(InputEvent),
529}
530
531/// Whether the native event pump should continue or exit.
532#[derive(Clone, Copy, Debug, Eq, PartialEq)]
533pub enum PumpStatus {
534    /// Continue processing the window and rendering future frames.
535    Continue,
536    /// Exit because the window has closed.
537    Exit,
538}
539
540#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
541impl Application {
542    /// Pumps native events until `window` first reports drawable metrics.
543    ///
544    /// Every correct application needs drawable metrics before opening graphics, so this wait is
545    /// owned here instead of being restated by each application.
546    ///
547    /// # Errors
548    ///
549    /// Returns an error when the native event pump fails or when the window closes before it
550    /// becomes drawable.
551    pub fn wait_for_first_metrics(
552        &mut self,
553        window: &Window,
554    ) -> Result<WindowMetrics, PlatformError> {
555        loop {
556            if let Some(metrics) = window.rendering_metrics() {
557                return Ok(metrics);
558            }
559            let mut first = None;
560            let status = self.pump_events(window, |event| {
561                if let WindowEvent::RenderingResumed(metrics)
562                | WindowEvent::RedrawRequested(metrics) = event
563                {
564                    first = Some(metrics);
565                }
566                Ok::<(), PlatformError>(())
567            })?;
568            if let Some(metrics) = first {
569                return Ok(metrics);
570            }
571            if status == PumpStatus::Exit {
572                return Err(PlatformError::lifecycle(
573                    "window closed before drawable metrics became available",
574                ));
575            }
576        }
577    }
578}
579
580/// The recovery-relevant category of a [`PlatformError`].
581#[derive(Clone, Copy, Debug, Eq, PartialEq)]
582#[non_exhaustive]
583pub enum PlatformErrorKind {
584    /// Application-provided window or event-loop values are invalid.
585    InvalidRequest,
586    /// The required native window system or capability is unavailable.
587    Unsupported,
588    /// The operation is invalid for the current application or window lifecycle state.
589    Lifecycle,
590    /// A native platform operation failed without stronger recovery evidence.
591    NativeFailure,
592    /// A Mulciber platform invariant failed internally.
593    Internal,
594}
595
596/// A platform creation or lifecycle error.
597#[derive(Clone, Debug, Eq, PartialEq)]
598pub struct PlatformError {
599    kind: PlatformErrorKind,
600    message: String,
601}
602
603impl PlatformError {
604    #[cfg_attr(
605        not(any(target_os = "macos", target_os = "linux", target_os = "windows")),
606        allow(dead_code)
607    )]
608    pub(crate) fn new(message: impl Into<String>) -> Self {
609        Self::with_kind(PlatformErrorKind::NativeFailure, message)
610    }
611
612    pub(crate) fn with_kind(kind: PlatformErrorKind, message: impl Into<String>) -> Self {
613        Self {
614            kind,
615            message: message.into(),
616        }
617    }
618
619    pub(crate) fn invalid_request(message: impl Into<String>) -> Self {
620        Self::with_kind(PlatformErrorKind::InvalidRequest, message)
621    }
622
623    pub(crate) fn lifecycle(message: impl Into<String>) -> Self {
624        Self::with_kind(PlatformErrorKind::Lifecycle, message)
625    }
626
627    /// Returns the recovery-relevant category while preserving native detail in [`Self::message`].
628    #[must_use]
629    pub const fn kind(&self) -> PlatformErrorKind {
630        self.kind
631    }
632
633    /// Returns the contextual diagnostic message.
634    #[must_use]
635    pub fn message(&self) -> &str {
636        &self.message
637    }
638}
639
640impl fmt::Display for PlatformError {
641    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
642        formatter.write_str(&self.message)
643    }
644}
645
646impl std::error::Error for PlatformError {}
647
648#[cfg(test)]
649mod tests {
650    use core::cell::Cell;
651
652    use super::{
653        LogicalSize, PhysicalExtent, PlatformError, PlatformErrorKind, WindowDescriptor,
654        WindowRevision, follow_confirmed_fullscreen,
655    };
656
657    #[test]
658    fn confirmed_fullscreen_transitions_drag_the_requested_mode() {
659        let confirmed = Cell::new(false);
660        let requested = Cell::new(true);
661
662        // A stale windowed report while the request is in flight must not cancel the intent.
663        follow_confirmed_fullscreen(&confirmed, &requested, false);
664        assert!(requested.get());
665
666        // The compositor confirming fullscreen keeps the intent aligned.
667        follow_confirmed_fullscreen(&confirmed, &requested, true);
668        assert!(requested.get());
669
670        // A window-system-initiated exit drags the requested mode back to windowed.
671        follow_confirmed_fullscreen(&confirmed, &requested, false);
672        assert!(!requested.get());
673
674        // A window-system-initiated entry drags the requested mode to fullscreen.
675        follow_confirmed_fullscreen(&confirmed, &requested, true);
676        assert!(requested.get());
677    }
678
679    #[test]
680    fn platform_errors_preserve_kind_and_message() {
681        let error = PlatformError::invalid_request("bad window request");
682        assert_eq!(error.kind(), PlatformErrorKind::InvalidRequest);
683        assert_eq!(error.message(), "bad window request");
684        assert_eq!(error.to_string(), "bad window request");
685    }
686
687    #[test]
688    fn empty_extent_requires_a_zero_dimension() {
689        assert!(PhysicalExtent::new(0, 9).is_empty());
690        assert!(PhysicalExtent::new(7, 0).is_empty());
691        assert!(!PhysicalExtent::new(7, 9).is_empty());
692    }
693
694    #[test]
695    fn window_revisions_are_monotonic() {
696        let initial = WindowRevision::INITIAL;
697        assert_eq!(initial.get(), 1);
698        assert_eq!(initial.next().get(), 2);
699    }
700
701    #[test]
702    fn window_descriptor_preserves_game_intent() {
703        let descriptor = WindowDescriptor::new("Mulciber", LogicalSize::new(960, 540));
704        assert_eq!(descriptor.title(), "Mulciber");
705        assert_eq!(descriptor.logical_size(), LogicalSize::new(960, 540));
706    }
707}