Skip to main content

apace_shell/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2
3//! Event loop, windowing and system integration.
4//!
5//! ## Feature flags
6//!  - `default`: Enables the features listed below.
7//!  - `accessibility`: Enables [`accesskit`] accessibility support.
8//!  - `hot-reload`: Enables hot-reloading of Dioxus RSX.
9//!  - `tracing`: Enables tracing support.
10
11mod application;
12mod convert_events;
13mod event;
14mod net;
15mod window;
16
17#[cfg(feature = "accessibility")]
18mod accessibility;
19
20pub use crate::application::ApaceApplication;
21pub use crate::event::{ApaceShellEvent, ApaceShellProxy};
22pub use crate::window::{View, WindowConfig};
23
24#[cfg(feature = "data-uri")]
25pub use crate::net::DataUriNetProvider;
26
27#[cfg(all(
28    feature = "file-dialog",
29    any(
30        target_os = "windows",
31        target_os = "macos",
32        target_os = "linux",
33        target_os = "dragonfly",
34        target_os = "freebsd",
35        target_os = "netbsd",
36        target_os = "openbsd"
37    )
38))]
39use apace_traits::shell::FileDialogFilter;
40use apace_traits::shell::{ImePurpose, ShellProvider};
41use std::sync::atomic::{AtomicBool, Ordering};
42use std::sync::{Arc, Mutex};
43use wingy::cursor::{Cursor, CursorIcon};
44use wingy::dpi::{LogicalPosition, LogicalSize};
45pub use wingy::event_loop::{ControlFlow, EventLoop, EventLoopProxy};
46pub use wingy::window::Window;
47use wingy::window::{
48    ImeCapabilities, ImeEnableRequest, ImeHint, ImePurpose as WindowImePurpose, ImeRequest,
49    ImeRequestData,
50};
51
52#[derive(Default)]
53pub struct Config {
54    pub stylesheets: Vec<String>,
55    pub base_url: Option<String>,
56}
57
58/// Build an event loop for the application
59pub fn create_default_event_loop() -> EventLoop {
60    let mut ev_builder = EventLoop::builder();
61    #[cfg(target_os = "android")]
62    {
63        use wingy::platform::android::EventLoopBuilderExtAndroid;
64        ev_builder.with_android_app(current_android_app());
65    }
66
67    let event_loop = ev_builder.build().unwrap();
68    event_loop.set_control_flow(ControlFlow::Wait);
69
70    event_loop
71}
72
73#[cfg(target_os = "android")]
74static ANDROID_APP: std::sync::OnceLock<android_activity::AndroidApp> = std::sync::OnceLock::new();
75
76#[cfg(target_os = "android")]
77#[cfg_attr(docsrs, doc(cfg(target_os = "android")))]
78/// Set the current [`AndroidApp`](android_activity::AndroidApp).
79pub fn set_android_app(app: android_activity::AndroidApp) {
80    ANDROID_APP.set(app).unwrap()
81}
82
83#[cfg(target_os = "android")]
84#[cfg_attr(docsrs, doc(cfg(target_os = "android")))]
85/// Get the current [`AndroidApp`](android_activity::AndroidApp).
86/// This will panic if the android activity has not been setup with [`set_android_app`].
87pub fn current_android_app() -> android_activity::AndroidApp {
88    ANDROID_APP.get().unwrap().clone()
89}
90
91pub struct ApaceShellProvider {
92    window: Arc<dyn Window>,
93    proxy: ApaceShellProxy,
94    ime_password: AtomicBool,
95    ime_enabled: AtomicBool,
96    ime_area: Mutex<Option<[f32; 4]>>,
97    #[cfg(any(
98        target_os = "ios",
99        all(target_os = "android", feature = "android-game-activity")
100    ))]
101    ime_context: Mutex<Option<(String, usize, usize)>>,
102}
103impl ApaceShellProvider {
104    pub fn new(window: Arc<dyn Window>, proxy: ApaceShellProxy) -> Self {
105        Self {
106            window,
107            proxy,
108            ime_password: AtomicBool::new(false),
109            ime_enabled: AtomicBool::new(false),
110            ime_area: Mutex::new(None),
111            #[cfg(any(
112                target_os = "ios",
113                all(target_os = "android", feature = "android-game-activity")
114            ))]
115            ime_context: Mutex::new(None),
116        }
117    }
118    fn ime_data(&self) -> ImeRequestData {
119        let purpose = if self.ime_password.load(Ordering::Relaxed) {
120            WindowImePurpose::Password
121        } else {
122            WindowImePurpose::Normal
123        };
124        ImeRequestData::default().with_hint_and_purpose(ImeHint::NONE, purpose)
125    }
126}
127
128impl ShellProvider for ApaceShellProvider {
129    fn set_ime_purpose(&self, purpose: ImePurpose) {
130        let password = purpose == ImePurpose::Password;
131        if self.ime_password.swap(password, Ordering::Relaxed) != password
132            && self.ime_enabled.load(Ordering::Relaxed)
133        {
134            let _ = self
135                .window
136                .request_ime_update(ImeRequest::Update(self.ime_data()));
137        }
138    }
139    fn request_redraw(&self) {
140        self.window.request_redraw();
141    }
142    fn set_cursor(&self, icon: Option<CursorIcon>) {
143        match icon {
144            Some(icon) => {
145                self.window.set_cursor_visible(true);
146                self.window.set_cursor(Cursor::Icon(icon));
147            }
148            None => {
149                self.window.set_cursor(Cursor::Icon(CursorIcon::Default));
150                self.window.set_cursor_visible(false)
151            }
152        }
153    }
154    fn set_window_title(&self, title: String) {
155        self.window.set_title(&title);
156    }
157    fn set_ime_enabled(&self, is_enabled: bool) {
158        *self.ime_area.lock().unwrap() = None;
159        #[cfg(any(
160            target_os = "ios",
161            all(target_os = "android", feature = "android-game-activity")
162        ))]
163        {
164            *self.ime_context.lock().unwrap() = None;
165        }
166        if is_enabled {
167            let data = self.ime_data().with_cursor_area(
168                LogicalPosition::new(0., 0.).into(),
169                LogicalSize::new(0., 0.).into(),
170            );
171            #[cfg(any(
172                target_os = "ios",
173                all(target_os = "android", feature = "android-game-activity")
174            ))]
175            let data = data.with_surrounding_text(
176                wingy::window::ImeSurroundingText::new(String::new(), 0, 0).unwrap(),
177            );
178            let capabilities = ImeCapabilities::new()
179                .with_hint_and_purpose()
180                .with_cursor_area();
181            #[cfg(any(
182                target_os = "ios",
183                all(target_os = "android", feature = "android-game-activity")
184            ))]
185            let capabilities = capabilities.with_surrounding_text();
186            let result = self.window.request_ime_update(ImeRequest::Enable(
187                ImeEnableRequest::new(capabilities, data).unwrap(),
188            ));
189            self.ime_enabled.store(result.is_ok(), Ordering::Relaxed);
190        } else {
191            self.ime_enabled.store(false, Ordering::Relaxed);
192            let _ = self.window.request_ime_update(ImeRequest::Disable);
193        }
194    }
195    fn set_ime_cursor_area(&self, x: f32, y: f32, width: f32, height: f32) {
196        if !self.ime_enabled.load(Ordering::Relaxed) {
197            return;
198        }
199        {
200            let mut previous = self.ime_area.lock().unwrap();
201            let area = [x, y, width, height];
202            if *previous == Some(area) {
203                return;
204            }
205            *previous = Some(area);
206        }
207        // Release the cache lock before asking the windowing system to update.
208        let _ = self.window.request_ime_update(ImeRequest::Update(
209            ImeRequestData::default().with_cursor_area(
210                LogicalPosition::new(x, y).into(),
211                LogicalSize::new(width, height).into(),
212            ),
213        ));
214    }
215
216    fn invalidate_ime_context(&self) {
217        #[cfg(any(
218            target_os = "ios",
219            all(target_os = "android", feature = "android-game-activity")
220        ))]
221        {
222            *self.ime_context.lock().unwrap() = None;
223        }
224    }
225    fn supports_ime_surrounding_text(&self) -> bool {
226        cfg!(any(
227            target_os = "ios",
228            all(target_os = "android", feature = "android-game-activity")
229        ))
230    }
231    fn set_ime_surrounding_text(&self, text: &str, cursor: usize, anchor: usize) {
232        #[cfg(any(
233            target_os = "ios",
234            all(target_os = "android", feature = "android-game-activity")
235        ))]
236        {
237            if !self.ime_enabled.load(Ordering::Relaxed) {
238                return;
239            }
240            let Ok(context) =
241                wingy::window::ImeSurroundingText::new(text.to_owned(), cursor, anchor)
242            else {
243                return;
244            };
245            {
246                let mut previous = self.ime_context.lock().unwrap();
247                if previous
248                    .as_ref()
249                    .is_some_and(|(s, c, a)| s == text && *c == cursor && *a == anchor)
250                {
251                    return;
252                }
253                *previous = Some((text.to_owned(), cursor, anchor));
254            }
255            let _ = self.window.request_ime_update(ImeRequest::Update(
256                ImeRequestData::default().with_surrounding_text(context),
257            ));
258        }
259        #[cfg(not(any(
260            target_os = "ios",
261            all(target_os = "android", feature = "android-game-activity")
262        )))]
263        let _ = (text, cursor, anchor);
264    }
265
266    fn request_window_close(&self) {
267        self.proxy.send_event(ApaceShellEvent::CloseWindow {
268            window_id: self.window.id(),
269        });
270    }
271    fn set_window_minimized(&self, minimized: bool) {
272        self.window.set_minimized(minimized);
273    }
274    fn set_window_maximized(&self, maximized: bool) {
275        self.window.set_maximized(maximized);
276    }
277    fn is_window_maximized(&self) -> bool {
278        self.window.is_maximized()
279    }
280    fn set_window_decorations(&self, decorations: bool) {
281        self.window.set_decorations(decorations);
282    }
283    fn drag_window(&self) {
284        let _ = self.window.drag_window();
285    }
286
287    #[cfg(all(
288        feature = "clipboard",
289        any(
290            target_os = "windows",
291            target_os = "macos",
292            target_os = "linux",
293            target_os = "dragonfly",
294            target_os = "freebsd",
295            target_os = "netbsd",
296            target_os = "openbsd"
297        )
298    ))]
299    fn get_clipboard_text(&self) -> Result<String, apace_traits::shell::ClipboardError> {
300        let mut cb = arboard::Clipboard::new().unwrap();
301        cb.get_text()
302            .map_err(|_| apace_traits::shell::ClipboardError)
303    }
304
305    #[cfg(all(
306        feature = "clipboard",
307        any(
308            target_os = "windows",
309            target_os = "macos",
310            target_os = "linux",
311            target_os = "dragonfly",
312            target_os = "freebsd",
313            target_os = "netbsd",
314            target_os = "openbsd"
315        )
316    ))]
317    fn set_clipboard_text(&self, text: String) -> Result<(), apace_traits::shell::ClipboardError> {
318        let mut cb = arboard::Clipboard::new().unwrap();
319        cb.set_text(text.to_owned())
320            .map_err(|_| apace_traits::shell::ClipboardError)
321    }
322
323    #[cfg(all(
324        feature = "file-dialog",
325        any(
326            target_os = "windows",
327            target_os = "macos",
328            target_os = "linux",
329            target_os = "dragonfly",
330            target_os = "freebsd",
331            target_os = "netbsd",
332            target_os = "openbsd"
333        )
334    ))]
335    fn open_file_dialog(
336        &self,
337        multiple: bool,
338        filter: Option<FileDialogFilter>,
339    ) -> Vec<std::path::PathBuf> {
340        let mut dialog = rfd::FileDialog::new();
341        if let Some(FileDialogFilter { name, extensions }) = filter {
342            dialog = dialog.add_filter(&name, &extensions);
343        }
344        let files = if multiple {
345            dialog.pick_files()
346        } else {
347            dialog.pick_file().map(|file| vec![file])
348        };
349        files.unwrap_or_default()
350    }
351}