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}