Seregon/StratoSDK

StratoSDK is a framework with a declarative approach similar to Flutter/React, written and designed entirely for Rust.

Rust/27.3 KB/No license
crates/strato-ui-renderer/src/windowing/winit/delegate.rs
1#![allow(unused)]
2 
3#[cfg(not(target_family = "wasm"))]
4mod global_hotkey;
5 
6use std::mem::ManuallyDrop;
7use std::{
8 cell::RefCell,
9 collections::HashMap,
10 path::{Path, PathBuf},
11 sync::{Arc, OnceLock},
12 thread::{self, panicking},
13};
14 
15use anyhow::Result;
16use geometry::rect::RectF;
17use itertools::Itertools;
18use parking_lot::Mutex;
19use serde::de::IntoDeserializer;
20use winit::event_loop::{ActiveEventLoop, EventLoopProxy};
21 
22use crate::platform::MicrophoneAccessState;
23use crate::platform::{
24 file_picker::{
25 FilePickerCallback, FilePickerError, SaveFilePickerCallback, SaveFilePickerConfiguration,
26 },
27 Cursor, RequestNotificationPermissionsCallback, SendNotificationErrorCallback,
28};
29use crate::windowing::winit::app::CustomEvent::UpdateUIApp;
30use crate::windowing::WindowManager;
31use crate::Effect::Event;
32use crate::{
33 accessibility,
34 clipboard::{self, ClipboardContent, InMemoryClipboard},
35 geometry, keymap,
36 modals::{AlertDialog, ModalId},
37 notification, platform,
38 platform::file_picker::{FilePickerConfiguration, FileType},
39 windowing::{self, WindowCallbacks},
40 AppContext, ApplicationBundleInfo, Clipboard, DisplayId, DisplayIdx, WindowId,
41};
42use crate::{
43 notification::{NotificationSendError, RequestPermissionsOutcome},
44 platform::TerminationMode,
45};
46 
47use super::{notifications, CustomEvent};
48 
49#[cfg(not(target_family = "wasm"))]
50use self::global_hotkey::GlobalHotKeyHandler;
51 
52// No-op on WASM since the browser cannot provide this functionality.
53#[cfg(target_family = "wasm")]
54struct GlobalHotKeyHandler {}
55 
56#[cfg(target_family = "wasm")]
57impl GlobalHotKeyHandler {
58 fn register(&self, _: keymap::Keystroke) {}
59 fn unregister(&self, _: &keymap::Keystroke) {}
60}
61 
62/// Stores the ID of the application's main thread, which we can reference
63/// to determine if a given thread is the main thread or not.
64static MAIN_THREAD_ID: OnceLock<thread::ThreadId> = OnceLock::new();
65 
66/// Open a URL using the platform's default handler.
67pub fn open_url_in_system(url: &str) {
68 #[cfg(target_family = "wasm")]
69 if let Some(window) = web_sys::window() {
70 // Try to open the URL in a new tab.
71 let _ = window.open_with_url_and_target(url, "_blank");
72 }
73 
74 #[cfg(target_os = "linux")]
75 {
76 // Opening in WSL is complicated for a few reasons
77 // 1. By default, wsl does not have an awareness of browsers installed in windows.
78 // We either need to have wslu installed for wslview, or we need
79 // 2. We do not necessarily have things like xdg-utils installed, so relying on
80 // "native" opening of files is not necessarily going to work.
81 // We choose to do the following:
82 // 1. First attempt to open with `wslview`, since that is basically made to open stuff in wsl
83 // 2. Use `cmd.exe /c start {url}` to open in the user's default windows browser
84 // - If a user does not want this behavior, and wants all opening to go through
85 // WSL, they can set the env variable WARP_FORCE_WSL_BROWSER.
86 // 3. Fall back to default linux url opening behavior.
87 if platform::linux::is_wsl() {
88 match open::with_detached(url, "wslview") {
89 Ok(_) => return,
90 Err(e) => log::info!(
91 "Failed to open url with wslview {e:?}, falling back to another method"
92 ),
93 };
94 
95 // Attempt to open by
96 if !use_wsl_browser() {
97 let mut cmd = std::process::Command::new("cmd.exe");
98 cmd.args(["/c", "start", url]);
99 
100 // Note: Ideally, we would be calling detached like open::that_detached does.
101 // However, it is probably fine.
102 match cmd
103 .stdin(std::process::Stdio::null())
104 .stdout(std::process::Stdio::null())
105 .stderr(std::process::Stdio::null())
106 .status()
107 {
108 Ok(_) => return,
109 Err(e) => log::info!(
110 "Failed to open url with cmd.exe {e:?}, falling back to another method"
111 ),
112 }
113 }
114 }
115 if let Err(e) = open::that_detached(url) {
116 log::warn!("Unable to open url {e:?}");
117 }
118 }
119 
120 #[cfg(windows)]
121 {
122 if let Err(e) = open::that_detached(url) {
123 log::warn!("Unable to open url {e:?}");
124 }
125 }
126}
127 
128#[cfg(target_os = "linux")]
129fn use_wsl_browser() -> bool {
130 static USE_WSL_BROWSER: OnceLock<bool> = OnceLock::new();
131 USE_WSL_BROWSER
132 .get_or_init(|| std::env::var("WARP_FORCE_WSL_BROWSER").is_ok())
133 .to_owned()
134}
135 
136/// Marks the current thread as the application's main thread.
137///
138/// # Panics
139///
140/// Panics if called more than once.
141pub(super) fn mark_current_thread_as_main() {
142 MAIN_THREAD_ID
143 .set(thread::current().id())
144 .expect("should only call mark_current_thread_as_main once!");
145}
146 
147pub struct DispatchDelegate {
148 event_loop_proxy: Mutex<EventLoopProxy<super::CustomEvent>>,
149}
150 
151impl platform::DispatchDelegate for DispatchDelegate {
152 fn is_main_thread(&self) -> bool {
153 thread::current().id()
154 == *MAIN_THREAD_ID
155 .get()
156 .expect("should have marked a thread as the main thread")
157 }
158 
159 fn run_on_main_thread(&self, task: async_task::Runnable) {
160 // Surround the `task` in a `ManuallyDrop` so we can control when the task gets dropped.
161 // If the event loop is no longer running, sending the task over a channel will fail which
162 // causes the `task` to be dropped by _this_ thread. This in turns triggers a panic in
163 // `async-task` since the future is dropped by a different thread than what spawned it.
164 // In the case the event loop is no longer running, we will end up leaking the task until
165 // the process exits (which should happen imminently given the event loop has terminated).
166 self.event_loop_proxy
167 .lock()
168 .send_event(super::CustomEvent::RunTask(ManuallyDrop::new(task)));
169 }
170}
171 
172pub struct AppDelegate {
173 /// A handle for enqueueing [`CustomEvent`]s into the main event loop.
174 pub(super) event_loop_proxy: EventLoopProxy<super::CustomEvent>,
175 
176 clipboard: Box<dyn Clipboard>,
177 
178 /// Responsible for registering the global hotkeys in the platform's desktop environment. Will
179 /// be `None` for platforms that can't support global hotkeys.
180 global_hotkey_handler: Option<GlobalHotKeyHandler>,
181 
182 #[cfg(feature = "test-util")]
183 last_known_cursor: RefCell<Cursor>,
184}
185 
186impl AppDelegate {
187 pub fn new(event_loop_proxy: EventLoopProxy<super::CustomEvent>) -> Result<Self> {
188 cfg_if::cfg_if! {
189 if #[cfg(target_family = "wasm")] {
190 let global_hotkey_handler = None;
191 } else {
192 let global_hotkey_handler = match GlobalHotKeyHandler::new(event_loop_proxy.clone()) {
193 Ok(handler) => Some(handler),
194 Err(err) => {
195 log::error!("Error creating global hotkey handler: {err:?}");
196 None
197 }
198 };
199 }
200 }
201 Ok(Self {
202 event_loop_proxy,
203 clipboard: Box::<InMemoryClipboard>::default(),
204 global_hotkey_handler,
205 #[cfg(feature = "test-util")]
206 last_known_cursor: RefCell::new(Cursor::Arrow),
207 })
208 }
209 
210 /// The way copy-paste is handled depends on the specific windowing system. As winit is
211 /// abstracting the windowing system, we need to ask it which one is running. We can do that by
212 /// matching against the display server raw handle.
213 pub fn use_platform_clipboard(&mut self) {
214 cfg_if::cfg_if! {
215 if #[cfg(target_family = "wasm")] {
216 self.clipboard = Box::new(super::wasm::WebClipboard::new());
217 } else if #[cfg(target_os = "linux")] {
218 match super::linux::LinuxClipboard::new() {
219 Ok(clipboard) => self.clipboard = Box::new(clipboard),
220 Err(err) => {
221 log::error!("Error creating Linux clipboard: {err:?}");
222 }
223 }
224 } else if #[cfg(target_os = "windows")] {
225 match super::windows::WindowsClipboard::new() {
226 Ok(clipboard) => self.clipboard = Box::new(clipboard),
227 Err(err) => {
228 log::error!("Error creating Windows clipboard: {err:?}");
229 }
230 }
231 }
232 }
233 }
234}
235 
236impl platform::Delegate for AppDelegate {
237 fn dispatch_delegate(&self) -> Arc<dyn platform::DispatchDelegate> {
238 Arc::new(DispatchDelegate {
239 event_loop_proxy: Mutex::new(self.event_loop_proxy.clone()),
240 })
241 }
242 
243 fn request_user_attention(&self, window_id: WindowId) {
244 self.event_loop_proxy
245 .send_event(CustomEvent::RequestUserAttention { window_id });
246 }
247 
248 fn clipboard(&mut self) -> &mut dyn crate::Clipboard {
249 self.clipboard.as_mut()
250 }
251 
252 #[cfg(not(target_family = "wasm"))]
253 fn system_theme(&self) -> platform::SystemTheme {
254 #[cfg(target_os = "linux")]
255 match super::linux::get_system_theme() {
256 Ok(system_theme) => {
257 return system_theme;
258 }
259 Err(err) => {
260 log::info!("Unable to fetch Linux system color scheme: {err:#}");
261 }
262 }
263 
264 #[cfg(target_os = "windows")]
265 match super::windows::get_system_theme() {
266 Ok(system_theme) => {
267 return system_theme;
268 }
269 Err(err) => {
270 log::warn!("Unable to fetch Windows system color scheme: {err:#?}");
271 }
272 }
273 
274 platform::SystemTheme::Light
275 }
276 
277 #[cfg(target_family = "wasm")]
278 fn system_theme(&self) -> platform::SystemTheme {
279 // To determine dark mode versus light mode, we check the CSS media query string "prefers-color-scheme". According
280 // to StackOverflow, this is the current consensus solution.
281 // See https://stackoverflow.com/questions/56393880/how-do-i-detect-dark-mode-using-javascript.
282 if let Ok(Some(media_query_list)) =
283 gloo::utils::window().match_media("(prefers-color-scheme: dark)")
284 {
285 if media_query_list.matches() {
286 return platform::SystemTheme::Dark;
287 }
288 }
289 platform::SystemTheme::Light
290 }
291 
292 fn open_url(&self, url: &str) {
293 open_url_in_system(url);
294 }
295 
296 fn open_file_path(&self, path: &Path) {
297 cfg_if::cfg_if! {
298 if #[cfg(target_os = "linux")] {
299 let _ = std::process::Command::new("xdg-open")
300 .arg(path)
301 .spawn();
302 } else if #[cfg(target_family = "wasm")] {
303 if let Some(window) = web_sys::window() {
304 if let Some(path) = path.to_str() {
305 // Try to open the path via a file:// URL.
306 let url = format!("file://{path}");
307 let _ = window.open_with_url(&url);
308 }
309 }
310 } else if #[cfg(windows)] {
311 if let Err(e) = open::that_detached(path) {
312 log::warn!("Unable to open path {e:?}");
313 }
314 }
315 }
316 }
317 
318 fn open_file_picker(
319 &self,
320 callback: FilePickerCallback,
321 file_picker_config: FilePickerConfiguration,
322 ) {
323 // TODO(wasm): Investigate implementing this by creating a <input> element
324 // and calling `click` on it.
325 
326 #[cfg(not(target_family = "wasm"))]
327 {
328 // This callback is called either on the “File Picker” background thread or, if starting
329 // that thread fails, on this thread. Wrap this type in order to make ownership work.
330 let callback = Arc::new(takecell::TakeOwnCell::new(callback));
331 let callback_clone = callback.clone();
332 
333 // Since native_dialog::FileDialog blocks while waiting for the user to select a file,
334 // put it in its own thread to avoid blocking the rest of the app.
335 let event_loop_proxy = self.event_loop_proxy.clone();
336 let thread_result = std::thread::Builder::new()
337 .name("File Picker".to_string())
338 .spawn(move || {
339 let file_type_names = file_picker_config
340 .file_types()
341 .iter()
342 .map(|file_type| file_type.display_name())
343 .join(", ");
344 let allowed_extensions = file_picker_config
345 .file_types()
346 .iter()
347 .map(|file_type| file_type.extensions())
348 .collect_vec()
349 .concat();
350 
351 // native-dialog doesn't support file-or-directory or multi-directory pickers,
352 // so if folders are allowed, it can only show a directory picker.
353 let result = if file_picker_config.allows_folder() {
354 native_dialog::FileDialog::new()
355 .set_title("Choose directory...")
356 .show_open_single_dir()
357 .map(|opt| opt.into_iter().collect())
358 .map_err(|e| FilePickerError::DialogFailed(e.to_string()))
359 } else {
360 let mut file_dialog =
361 native_dialog::FileDialog::new().set_title("Choose file...");
362 if !allowed_extensions.is_empty() {
363 file_dialog = file_dialog.add_filter(
364 file_type_names.as_str(),
365 allowed_extensions.as_slice(),
366 );
367 }
368 if file_picker_config.allows_multi_select() {
369 file_dialog
370 .show_open_multiple_file()
371 .map_err(|e| FilePickerError::DialogFailed(e.to_string()))
372 } else {
373 file_dialog
374 .show_open_single_file()
375 .map(|opt| opt.into_iter().collect())
376 .map_err(|e| FilePickerError::DialogFailed(e.to_string()))
377 }
378 };
379 
380 let result =
381 result.and_then(|file_result| {
382 file_result
383 .iter()
384 .map(|path_buf| {
385 path_buf.as_os_str().to_str().map(String::from).ok_or_else(
386 || {
387 FilePickerError::DialogFailed(format!(
388 "Invalid path encoding: {:?}",
389 path_buf
390 ))
391 },
392 )
393 })
394 .collect::<Result<Vec<_>, _>>()
395 });
396 
397 event_loop_proxy.send_event(CustomEvent::UpdateUIApp(Box::new(move |app| {
398 if let Some(callback) = callback_clone.take() {
399 callback(result, app);
400 }
401 })));
402 });
403 if let Err(e) = thread_result {
404 self.event_loop_proxy
405 .send_event(CustomEvent::UpdateUIApp(Box::new(move |app| {
406 if let Some(callback) = callback.take() {
407 callback(Err(FilePickerError::ThreadSpawnFailed(Arc::new(e))), app);
408 }
409 })));
410 }
411 }
412 }
413 
414 fn open_save_file_picker(
415 &self,
416 callback: SaveFilePickerCallback,
417 config: SaveFilePickerConfiguration,
418 ) {
419 #[cfg(not(target_family = "wasm"))]
420 {
421 let event_loop_proxy = self.event_loop_proxy.clone();
422 std::thread::Builder::new()
423 .name("Save File Picker".to_string())
424 .spawn(move || {
425 let mut file_dialog =
426 native_dialog::FileDialog::new().set_title("Save file as...");
427 
428 if let Some(default_filename) = config.default_filename.as_ref() {
429 file_dialog = file_dialog.set_filename(default_filename);
430 }
431 
432 if let Some(default_directory) = config.default_directory.as_ref() {
433 file_dialog = file_dialog.set_location(default_directory);
434 }
435 
436 let file_result = file_dialog.show_save_single_file().unwrap_or_else(|err| {
437 log::error!("unable to show save file dialog: {err:?}");
438 None
439 });
440 
441 let path = file_result
442 .and_then(|path_buf| path_buf.as_os_str().to_str().map(String::from));
443 
444 event_loop_proxy.send_event(CustomEvent::UpdateUIApp(Box::new(|app| {
445 callback(path, app);
446 })));
447 });
448 }
449 }
450 
451 fn application_bundle_info(
452 &self,
453 bundle_identifier: &str,
454 ) -> Option<ApplicationBundleInfo<'_>> {
455 None
456 }
457 
458 fn request_desktop_notification_permissions(
459 &self,
460 on_completion: RequestNotificationPermissionsCallback,
461 ) {
462 notifications::request_desktop_notification_permissions(
463 on_completion,
464 &self.event_loop_proxy,
465 );
466 }
467 
468 #[cfg(feature = "test-util")]
469 fn get_cursor_shape(&self) -> Cursor {
470 *self.last_known_cursor.borrow()
471 }
472 
473 fn send_desktop_notification(
474 &self,
475 notification_content: notification::UserNotification,
476 window_id: WindowId,
477 on_error: SendNotificationErrorCallback,
478 ) {
479 notifications::send_desktop_notification(
480 notification_content,
481 window_id,
482 on_error,
483 &self.event_loop_proxy,
484 )
485 }
486 
487 fn set_cursor_shape(&self, cursor: Cursor) {
488 #[cfg(test)]
489 {
490 *self.last_known_cursor.borrow_mut() = cursor;
491 }
492 self.event_loop_proxy
493 .send_event(CustomEvent::SetCursorShape(cursor));
494 }
495 
496 fn close_ime_async(&self, _window_id: WindowId) {
497 // TODO(wasm): implement this.
498 }
499 
500 fn is_ime_open(&self) -> bool {
501 // TODO(wasm): implement this.
502 false
503 }
504 
505 fn open_character_palette(&self) {
506 // TODO(wasm): Implement this.
507 }
508 
509 fn set_accessibility_contents(&self, content: accessibility::AccessibilityContent) {
510 // TODO(wasm): Implement this.
511 }
512 
513 fn register_global_shortcut(&self, shortcut: keymap::Keystroke) {
514 if let Some(handler) = &self.global_hotkey_handler {
515 handler.register(shortcut);
516 }
517 }
518 
519 fn unregister_global_shortcut(&self, shortcut: &keymap::Keystroke) {
520 if let Some(handler) = &self.global_hotkey_handler {
521 handler.unregister(shortcut);
522 }
523 }
524 
525 fn terminate_app(&self, terminaton_mode: TerminationMode) {
526 self.event_loop_proxy
527 .send_event(CustomEvent::Terminate(terminaton_mode));
528 }
529 
530 fn is_screen_reader_enabled(&self) -> Option<bool> {
531 // TODO(wasm): Implement this.
532 None
533 }
534 
535 fn microphone_access_state(&self) -> MicrophoneAccessState {
536 // Note that for voice input, we can actually detect microphone access state
537 // in the course of trying to start voice input, but we don't have a way to do
538 // it at arbitrary times, so we just return NotDetermined here.
539 MicrophoneAccessState::NotDetermined
540 }
541 
542 fn open_file_path_in_explorer(&self, path: &Path) {
543 if path.is_dir() {
544 self.open_file_path(path);
545 } else if let Some(parent_path) = path.parent() {
546 if parent_path.is_dir() {
547 self.open_file_path(parent_path);
548 } else {
549 log::info!("Parent directory is not a valid directory, not opening file")
550 }
551 } else {
552 log::info!("Neither file nor parent was a valid directory, not opening file");
553 }
554 }
555 
556 fn show_native_platform_modal(&self, _id: ModalId, _modal: AlertDialog) {
557 // TODO
558 }
559}
560 
561pub struct IntegrationTestDelegate {
562 app_delegate: AppDelegate,
563 clipboard: InMemoryClipboard,
564}
565 
566impl IntegrationTestDelegate {
567 pub fn new(event_loop_proxy: EventLoopProxy<super::CustomEvent>) -> Result<Self> {
568 Ok(IntegrationTestDelegate {
569 app_delegate: AppDelegate::new(event_loop_proxy)?,
570 clipboard: InMemoryClipboard::default(),
571 })
572 }
573}
574 
575impl platform::Delegate for IntegrationTestDelegate {
576 fn dispatch_delegate(&self) -> Arc<dyn platform::DispatchDelegate> {
577 self.app_delegate.dispatch_delegate()
578 }
579 
580 fn request_user_attention(&self, _window_id: WindowId) {
581 // no-op
582 }
583 
584 fn clipboard(&mut self) -> &mut dyn crate::Clipboard {
585 &mut self.clipboard
586 }
587 
588 fn system_theme(&self) -> platform::SystemTheme {
589 self.app_delegate.system_theme()
590 }
591 
592 fn open_url(&self, _: &str) {
593 // no-op
594 }
595 
596 fn open_file_path(&self, _: &Path) {
597 // no-op
598 }
599 
600 fn open_file_picker(
601 &self,
602 _callback: FilePickerCallback,
603 _file_picker_config: FilePickerConfiguration,
604 ) {
605 // no-op
606 }
607 
608 fn open_save_file_picker(
609 &self,
610 _callback: SaveFilePickerCallback,
611 _config: SaveFilePickerConfiguration,
612 ) {
613 // no-op
614 }
615 
616 fn application_bundle_info(&self, _: &str) -> Option<ApplicationBundleInfo<'_>> {
617 None
618 }
619 
620 fn microphone_access_state(&self) -> MicrophoneAccessState {
621 MicrophoneAccessState::NotDetermined
622 }
623 
624 fn request_desktop_notification_permissions(
625 &self,
626 _on_completion: RequestNotificationPermissionsCallback,
627 ) {
628 // no-op
629 }
630 
631 fn send_desktop_notification(
632 &self,
633 _notification_content: notification::UserNotification,
634 _window_id: WindowId,
635 _on_error: SendNotificationErrorCallback,
636 ) {
637 // no-op
638 }
639 
640 #[cfg(feature = "test-util")]
641 fn get_cursor_shape(&self) -> platform::Cursor {
642 self.app_delegate.get_cursor_shape()
643 }
644 
645 fn set_cursor_shape(&self, cursor: platform::Cursor) {
646 self.app_delegate.set_cursor_shape(cursor)
647 }
648 
649 fn close_ime_async(&self, _window_id: WindowId) {
650 // no-op
651 }
652 
653 fn is_ime_open(&self) -> bool {
654 false
655 }
656 
657 fn open_character_palette(&self) {
658 // no-op
659 }
660 
661 fn set_accessibility_contents(&self, _: accessibility::AccessibilityContent) {
662 // no-op
663 }
664 
665 fn register_global_shortcut(&self, shortcut: keymap::Keystroke) {
666 self.app_delegate.register_global_shortcut(shortcut)
667 }
668 
669 fn unregister_global_shortcut(&self, shortcut: &keymap::Keystroke) {
670 self.app_delegate.unregister_global_shortcut(shortcut)
671 }
672 
673 fn terminate_app(&self, termination_mode: TerminationMode) {
674 self.app_delegate.terminate_app(termination_mode);
675 }
676 
677 fn is_screen_reader_enabled(&self) -> Option<bool> {
678 self.app_delegate.is_screen_reader_enabled()
679 }
680 
681 fn open_file_path_in_explorer(&self, path: &Path) {
682 // no-op
683 }
684 
685 fn show_native_platform_modal(&self, _id: ModalId, _modal: AlertDialog) {
686 // no-op
687 }
688}
689