Skip to main content

spatialrust_viewer/
native.rs

1use std::sync::{Arc, Mutex};
2
3use winit::{
4    application::ApplicationHandler,
5    dpi::LogicalSize,
6    event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent},
7    event_loop::{ActiveEventLoop, EventLoop},
8    window::{Window, WindowAttributes, WindowId},
9};
10
11use crate::{InputAction, ViewerController, ViewerError, ViewerResult, ViewerState, ViewportSize};
12
13/// Native window creation options.
14#[derive(Clone, Debug, PartialEq)]
15pub struct NativeViewerOptions {
16    /// Window title.
17    pub title: String,
18    /// Initial logical width.
19    pub width: u32,
20    /// Initial logical height.
21    pub height: u32,
22}
23
24impl Default for NativeViewerOptions {
25    fn default() -> Self {
26        Self { title: "SpatialRust Viewer".into(), width: 1280, height: 720 }
27    }
28}
29
30/// Opt-in native window shell backed by winit.
31///
32/// It owns no geometry and performs no implicit upload. Applications explicitly
33/// upload geometry through `spatialrust-render-wgpu`, then use this shell for
34/// portable input and layer/inspector state.
35pub struct NativeViewer {
36    state: Arc<Mutex<ViewerState>>,
37    controller: ViewerController,
38    options: NativeViewerOptions,
39}
40
41impl NativeViewer {
42    /// Creates a native viewer shell.
43    pub fn try_new(state: ViewerState, options: NativeViewerOptions) -> ViewerResult<Self> {
44        if options.title.trim().is_empty() || options.width == 0 || options.height == 0 {
45            return Err(ViewerError::Native(
46                "title and non-zero window dimensions are required".into(),
47            ));
48        }
49        Ok(Self {
50            state: Arc::new(Mutex::new(state)),
51            controller: ViewerController::default(),
52            options,
53        })
54    }
55
56    /// Shared state handle for application render/UI integration.
57    #[must_use]
58    pub fn state(&self) -> Arc<Mutex<ViewerState>> {
59        Arc::clone(&self.state)
60    }
61
62    /// Opens the native window and runs until the user closes it.
63    pub fn run(self) -> ViewerResult<()> {
64        let event_loop =
65            EventLoop::new().map_err(|error| ViewerError::Native(error.to_string()))?;
66        let mut application = NativeApplication {
67            state: self.state,
68            controller: self.controller,
69            options: self.options,
70            window: None,
71            cursor: None,
72            drag: None,
73        };
74        event_loop.run_app(&mut application).map_err(|error| ViewerError::Native(error.to_string()))
75    }
76}
77
78struct NativeApplication {
79    state: Arc<Mutex<ViewerState>>,
80    controller: ViewerController,
81    options: NativeViewerOptions,
82    window: Option<Window>,
83    cursor: Option<(f64, f64)>,
84    drag: Option<MouseButton>,
85}
86
87impl NativeApplication {
88    fn apply(&self, action: InputAction) {
89        if let Ok(mut state) = self.state.lock() {
90            let _ = self.controller.apply(&mut state, action);
91        }
92    }
93}
94
95impl ApplicationHandler for NativeApplication {
96    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
97        if self.window.is_some() {
98            return;
99        }
100        let attributes = WindowAttributes::default()
101            .with_title(self.options.title.clone())
102            .with_inner_size(LogicalSize::new(self.options.width, self.options.height));
103        match event_loop.create_window(attributes) {
104            Ok(window) => self.window = Some(window),
105            Err(error) => {
106                eprintln!("SpatialRust native viewer: {error}");
107                event_loop.exit();
108            }
109        }
110    }
111
112    fn window_event(
113        &mut self,
114        event_loop: &ActiveEventLoop,
115        window_id: WindowId,
116        event: WindowEvent,
117    ) {
118        if self.window.as_ref().map_or(true, |window| window.id() != window_id) {
119            return;
120        }
121        match event {
122            WindowEvent::CloseRequested => event_loop.exit(),
123            WindowEvent::Resized(size) if size.width > 0 && size.height > 0 => {
124                if let Ok(viewport) = ViewportSize::try_new(size.width, size.height) {
125                    self.apply(InputAction::Resize(viewport));
126                }
127            }
128            WindowEvent::DroppedFile(path) => {
129                self.apply(InputAction::FileDropped(path.to_string_lossy().into_owned()));
130            }
131            WindowEvent::MouseInput { state, button, .. } => {
132                self.drag = (state == ElementState::Pressed).then_some(button);
133            }
134            WindowEvent::CursorMoved { position, .. } => {
135                if let (Some((last_x, last_y)), Some(button)) = (self.cursor, self.drag) {
136                    let delta_x = (position.x - last_x) as f32;
137                    let delta_y = (position.y - last_y) as f32;
138                    let action = if button == MouseButton::Left {
139                        InputAction::Orbit { delta_x, delta_y }
140                    } else {
141                        InputAction::Pan { delta_x, delta_y }
142                    };
143                    self.apply(action);
144                }
145                self.cursor = Some((position.x, position.y));
146            }
147            WindowEvent::MouseWheel { delta, .. } => {
148                let amount = match delta {
149                    MouseScrollDelta::LineDelta(_, y) => y,
150                    MouseScrollDelta::PixelDelta(position) => position.y as f32 / 40.0,
151                };
152                self.apply(InputAction::Zoom(amount));
153            }
154            WindowEvent::RedrawRequested => {
155                // The application owns explicit wgpu upload/render integration.
156                // Continuous redraw keeps camera/UI observers responsive.
157                if let Some(window) = &self.window {
158                    window.request_redraw();
159                }
160            }
161            _ => {}
162        }
163    }
164}