Skip to main content

goud_engine/sdk/
window.rs

1//! # SDK Window Management API
2//!
3//! Provides methods on [`GoudGame`](super::GoudGame) for window lifecycle
4//! management: polling events, swapping buffers, querying window state, and
5//! clearing the screen. Also provides static lifecycle functions on [`Window`]
6//! for creating and destroying windowed contexts from FFI.
7//!
8//! # Availability
9//!
10//! This module requires the `native` feature (desktop platform with GLFW).
11//! Window methods are only available when GoudGame has been initialized with
12//! a platform backend (i.e., when running on desktop, not in headless/test mode).
13//!
14//! # Example
15//!
16//! ```rust,ignore
17//! use goud_engine::sdk::{GoudGame, GameConfig};
18//!
19//! let mut game = GoudGame::new(GameConfig::default()).unwrap();
20//!
21//! // Main game loop
22//! while !game.should_close() {
23//!     let dt = game.poll_events();
24//!     // ... update game logic with dt ...
25//!     // ... render ...
26//!     game.swap_buffers();
27//! }
28//! ```
29
30use super::GoudGame;
31use crate::context_registry::{get_context_registry, GoudContextId, GOUD_INVALID_CONTEXT_ID};
32use crate::core::error::{set_last_error, GoudError, GoudResult};
33use crate::libs::graphics::backend::{ClearOps, StateOps};
34
35// =============================================================================
36// Window Lifecycle (static functions for FFI context creation/destruction)
37// =============================================================================
38
39/// Zero-sized type that hosts window lifecycle functions.
40///
41/// All methods are static (no `self` receiver) and generate FFI wrappers
42/// via the `#[goud_api]` proc-macro.
43pub struct Window;
44
45// NOTE: FFI wrappers are hand-written in ffi/window.rs. The `#[goud_api]`
46// attribute is omitted here to avoid duplicate `#[no_mangle]` symbol conflicts.
47impl Window {
48    /// Creates a new context with an empty ECS World.
49    ///
50    /// Platform and rendering state are managed separately (e.g., via the
51    /// FFI `WindowState` in `ffi/window.rs`). The context only owns a World
52    /// for ECS storage.
53    ///
54    /// Returns a context ID on success, or `GOUD_INVALID_CONTEXT_ID` on
55    /// failure.
56    pub fn create(_width: u32, _height: u32, _title: &str) -> GoudContextId {
57        // Allocate a context slot in the registry.
58        // Platform and rendering state are managed separately (e.g., via
59        // the FFI WindowState in ffi/window.rs). The context only owns a
60        // World for ECS storage.
61        let mut registry = match get_context_registry().lock() {
62            Ok(r) => r,
63            Err(_) => {
64                set_last_error(GoudError::InternalError(
65                    "Failed to lock context registry".to_string(),
66                ));
67                return GOUD_INVALID_CONTEXT_ID;
68            }
69        };
70
71        match registry.create() {
72            Ok(id) => id,
73            Err(e) => {
74                set_last_error(e);
75                GOUD_INVALID_CONTEXT_ID
76            }
77        }
78    }
79
80    /// Destroys a windowed context and releases all resources.
81    ///
82    /// This destroys the window, OpenGL context, and ECS world.
83    ///
84    /// Returns `true` on success, `false` on error.
85    pub fn destroy(context_id: GoudContextId) -> bool {
86        if context_id == GOUD_INVALID_CONTEXT_ID {
87            set_last_error(GoudError::InvalidContext);
88            return false;
89        }
90
91        let mut registry = match get_context_registry().lock() {
92            Ok(r) => r,
93            Err(_) => {
94                set_last_error(GoudError::InternalError(
95                    "Failed to lock context registry".to_string(),
96                ));
97                return false;
98            }
99        };
100
101        match registry.destroy(context_id) {
102            Ok(()) => true,
103            Err(e) => {
104                set_last_error(e);
105                false
106            }
107        }
108    }
109}
110
111// =============================================================================
112// Window Instance Methods (on GoudGame)
113// =============================================================================
114
115// Instance methods on GoudGame for window operations.
116// FFI wrappers are generated by the `#[goud_api]` block on `impl Window` above,
117// or exist hand-written in `ffi/window.rs`. This block is NOT annotated because
118// the proc macro does not support multiple invocations per file.
119impl GoudGame {
120    /// Returns `true` if the window has been requested to close.
121    ///
122    /// This checks whether the user clicked the close button, pressed Alt+F4,
123    /// or called [`set_should_close`](Self::set_should_close).
124    ///
125    /// Returns `false` if no platform backend is initialized (headless mode).
126    #[inline]
127    pub fn should_close(&self) -> bool {
128        match &self.platform {
129            Some(platform) => platform.should_close(),
130            None => false,
131        }
132    }
133
134    /// Signals the window to close (or not) after the current frame.
135    ///
136    /// # Errors
137    ///
138    /// Returns an error if no platform backend is initialized.
139    pub fn set_should_close(&mut self, should_close: bool) -> GoudResult<()> {
140        match &mut self.platform {
141            Some(platform) => {
142                platform.set_should_close(should_close);
143                Ok(())
144            }
145            None => Err(GoudError::NotInitialized),
146        }
147    }
148
149    /// Polls platform events and advances input state for the new frame.
150    ///
151    /// This processes all pending window/input events, syncs the OpenGL
152    /// viewport on window resize, and returns the delta time (seconds since
153    /// last call). Must be called once per frame before querying input.
154    ///
155    /// # Errors
156    ///
157    /// Returns an error if no platform backend is initialized.
158    pub fn poll_events(&mut self) -> GoudResult<f32> {
159        let (dt, fb_size) = match &mut self.platform {
160            Some(platform) => {
161                let dt = platform.poll_events(&mut self.input_manager);
162                let fb_size = platform.get_framebuffer_size();
163                (dt, fb_size)
164            }
165            None => return Err(GoudError::NotInitialized),
166        };
167
168        // Sync the render backend viewport to the current framebuffer size.
169        if let Some(backend) = &mut self.render_backend {
170            backend.set_viewport(0, 0, fb_size.0, fb_size.1);
171        }
172
173        self.context.update(dt);
174        Ok(dt)
175    }
176
177    /// Presents the rendered frame by swapping front and back buffers.
178    ///
179    /// Call this at the end of each frame after all rendering is complete.
180    ///
181    /// # Errors
182    ///
183    /// Returns an error if no platform backend is initialized.
184    pub fn swap_buffers(&mut self) -> GoudResult<()> {
185        match &mut self.platform {
186            Some(platform) => {
187                platform.swap_buffers();
188                Ok(())
189            }
190            None => Err(GoudError::NotInitialized),
191        }
192    }
193
194    /// Clears the window with the specified color.
195    ///
196    /// Sets the clear color and clears the color buffer using the
197    /// render backend.
198    pub fn clear(&mut self, r: f32, g: f32, b: f32, a: f32) {
199        if let Some(backend) = &mut self.render_backend {
200            backend.set_clear_color(r, g, b, a);
201            backend.clear_color();
202        }
203    }
204
205    /// Returns the physical window size `(width, height)` in pixels.
206    ///
207    /// If no platform backend is initialized, returns the configured size
208    /// from [`GameConfig`](super::GameConfig).
209    #[inline]
210    pub fn get_window_size(&self) -> (u32, u32) {
211        match &self.platform {
212            Some(platform) => platform.get_size(),
213            None => (self.config.width, self.config.height),
214        }
215    }
216
217    /// Returns `true` if a platform backend is initialized.
218    ///
219    /// When `false`, window/rendering methods will return errors or
220    /// fall back to headless behavior.
221    #[inline]
222    pub fn has_platform(&self) -> bool {
223        self.platform.is_some()
224    }
225
226    /// Returns the delta time (seconds since last `poll_events` call).
227    ///
228    /// This reads from the internal `GameContext` which is updated by
229    /// [`poll_events`](Self::poll_events). Returns `0.0` before the first
230    /// poll.
231    #[inline]
232    pub fn get_delta_time(&self) -> f32 {
233        self.context.delta_time()
234    }
235
236    /// Returns the physical framebuffer size `(width, height)` in pixels.
237    ///
238    /// On HiDPI/Retina displays, this may differ from the logical window
239    /// size returned by [`get_window_size`](Self::get_window_size).
240    /// Renderers should use this for `gl::Viewport`.
241    ///
242    /// If no platform backend is initialized, returns the configured size.
243    #[inline]
244    pub fn get_framebuffer_size(&self) -> (u32, u32) {
245        match &self.platform {
246            Some(platform) => platform.get_framebuffer_size(),
247            None => (self.config.width, self.config.height),
248        }
249    }
250}
251
252// =============================================================================
253// Tests
254// =============================================================================
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::sdk::GameConfig;
260
261    #[test]
262    fn test_should_close_headless() {
263        let game = GoudGame::new(GameConfig::default()).unwrap();
264        // No platform => should_close returns false
265        assert!(!game.should_close());
266    }
267
268    #[test]
269    fn test_set_should_close_headless_returns_error() {
270        let mut game = GoudGame::new(GameConfig::default()).unwrap();
271        let result = game.set_should_close(true);
272        assert!(result.is_err());
273    }
274
275    #[test]
276    fn test_poll_events_headless_returns_error() {
277        let mut game = GoudGame::new(GameConfig::default()).unwrap();
278        let result = game.poll_events();
279        assert!(result.is_err());
280    }
281
282    #[test]
283    fn test_swap_buffers_headless_returns_error() {
284        let mut game = GoudGame::new(GameConfig::default()).unwrap();
285        let result = game.swap_buffers();
286        assert!(result.is_err());
287    }
288
289    #[test]
290    fn test_clear_headless_no_panic() {
291        let mut game = GoudGame::new(GameConfig::default()).unwrap();
292        // No backend => clear is a no-op, should not panic
293        game.clear(0.0, 0.0, 0.0, 1.0);
294    }
295
296    #[test]
297    fn test_get_window_size_headless() {
298        let game = GoudGame::new(GameConfig::new("Test", 1280, 720)).unwrap();
299        // No platform => falls back to config size
300        assert_eq!(game.get_window_size(), (1280, 720));
301    }
302
303    #[test]
304    fn test_has_platform_headless() {
305        let game = GoudGame::new(GameConfig::default()).unwrap();
306        assert!(!game.has_platform());
307    }
308
309    #[test]
310    fn test_get_delta_time_initial() {
311        let game = GoudGame::new(GameConfig::default()).unwrap();
312        assert!((game.get_delta_time() - 0.0).abs() < 0.001);
313    }
314
315    #[test]
316    fn test_get_framebuffer_size_headless() {
317        let game = GoudGame::new(GameConfig::new("Test", 1920, 1080)).unwrap();
318        // No platform => falls back to config size
319        assert_eq!(game.get_framebuffer_size(), (1920, 1080));
320    }
321
322    #[test]
323    fn test_window_destroy_invalid_returns_false() {
324        assert!(!Window::destroy(GOUD_INVALID_CONTEXT_ID));
325    }
326}