1use crate::ffi;
2pub use crate::ffi::{HostCapability, HostEnvironment, PlatformFamily};
3use crate::generated::framework_host_services;
4
5#[cfg(feature = "native-runtime")]
6use std::cell::RefCell;
7#[cfg(feature = "native-runtime")]
8use std::collections::HashMap;
9#[cfg(feature = "native-runtime")]
10use std::path::Path;
11#[cfg(feature = "native-runtime")]
12use std::path::PathBuf;
13#[cfg(feature = "native-runtime")]
14use std::sync::atomic::{AtomicU64, Ordering};
15
16#[cfg(feature = "native-runtime")]
17unsafe extern "C" {
18 fn fui_dispatch_to_ui(callback_id: u64) -> bool;
19 fn fui_cancel_ui_dispatch_async(callback_id: u64) -> bool;
20 fn fui_native_clipboard_write(text: *const u8, length: u32) -> bool;
21 fn fui_native_clipboard_text_length() -> u32;
22 fn fui_native_clipboard_copy(destination: *mut u8, capacity: u32) -> u32;
23 fn fui_native_open_external_url(value: *const u8, length: u32) -> bool;
24 fn fui_native_open_file(value: *const u8, length: u32) -> bool;
25 fn fui_native_reveal_file(value: *const u8, length: u32) -> bool;
26 fn fui_native_show_file_dialog(
27 kind: u32,
28 request_id: u64,
29 filters: *const u8,
30 filters_length: u32,
31 default_location: *const u8,
32 default_location_length: u32,
33 allow_multiple: bool,
34 ) -> bool;
35}
36
37#[cfg(feature = "native-runtime")]
38thread_local! {
39 static UI_DISPATCH_CALLBACKS: RefCell<HashMap<u64, Box<dyn FnOnce()>>> = RefCell::new(HashMap::new());
40}
41
42#[cfg(feature = "native-runtime")]
43static NEXT_UI_DISPATCH_ID: AtomicU64 = AtomicU64::new(1);
44
45#[cfg(feature = "native-runtime")]
46static NEXT_NATIVE_FILE_DIALOG_ID: AtomicU64 = AtomicU64::new(1);
47
48#[cfg(feature = "native-runtime")]
49type NativeFileDialogCallback = Box<dyn FnOnce(NativeFileDialogResult)>;
50
51#[cfg(feature = "native-runtime")]
52thread_local! {
53 static NATIVE_FILE_DIALOG_CALLBACKS: RefCell<HashMap<u64, NativeFileDialogCallback>> = RefCell::new(HashMap::new());
54}
55
56#[cfg(feature = "native-runtime")]
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct NativeFileFilter {
59 pub name: String,
60 pub extensions: Vec<String>,
61}
62
63#[cfg(feature = "native-runtime")]
64impl NativeFileFilter {
65 pub fn new(
66 name: impl Into<String>,
67 extensions: impl IntoIterator<Item = impl Into<String>>,
68 ) -> Self {
69 Self {
70 name: name.into(),
71 extensions: extensions.into_iter().map(Into::into).collect(),
72 }
73 }
74}
75
76#[cfg(feature = "native-runtime")]
77#[derive(Clone, Debug, Default, PartialEq, Eq)]
78pub struct NativeFileDialogOptions {
79 pub filters: Vec<NativeFileFilter>,
80 pub default_location: Option<PathBuf>,
81 pub allow_multiple: bool,
82}
83
84#[cfg(feature = "native-runtime")]
85#[derive(Clone, Debug, PartialEq, Eq)]
86pub enum NativeFileDialogResult {
87 Selected {
88 paths: Vec<PathBuf>,
89 selected_filter: Option<usize>,
90 },
91 Cancelled,
92 Error(String),
93}
94
95#[cfg(feature = "native-runtime")]
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub struct NativeFileDialogRequest {
98 request_id: u64,
99}
100
101#[cfg(feature = "native-runtime")]
102impl NativeFileDialogRequest {
103 pub fn id(self) -> u64 {
104 self.request_id
105 }
106}
107
108#[cfg(feature = "native-runtime")]
110pub struct UiDispatchHandle {
111 callback_id: u64,
112 pending: bool,
113}
114
115#[cfg(feature = "native-runtime")]
116impl UiDispatchHandle {
117 pub fn dispatch(mut self) -> bool {
118 let dispatched = unsafe { fui_dispatch_to_ui(self.callback_id) };
119 if dispatched {
120 self.pending = false;
121 }
122 dispatched
123 }
124}
125
126#[cfg(feature = "native-runtime")]
127impl Drop for UiDispatchHandle {
128 fn drop(&mut self) {
129 if self.pending {
130 unsafe {
131 fui_cancel_ui_dispatch_async(self.callback_id);
132 }
133 }
134 }
135}
136
137#[cfg(feature = "native-runtime")]
138pub struct UiDispatcher;
139
140#[cfg(feature = "native-runtime")]
141impl UiDispatcher {
142 pub fn prepare(callback: impl FnOnce() + 'static) -> UiDispatchHandle {
144 let callback_id = NEXT_UI_DISPATCH_ID.fetch_add(1, Ordering::Relaxed);
145 UI_DISPATCH_CALLBACKS.with(|callbacks| {
146 callbacks
147 .borrow_mut()
148 .insert(callback_id, Box::new(callback));
149 });
150 UiDispatchHandle {
151 callback_id,
152 pending: true,
153 }
154 }
155}
156
157#[cfg(feature = "native-runtime")]
158pub fn write_clipboard_text(text: &str) -> bool {
159 unsafe { fui_native_clipboard_write(text.as_ptr(), text.len() as u32) }
160}
161
162#[cfg(feature = "native-runtime")]
163pub fn read_clipboard_text() -> Option<String> {
164 let length = unsafe { fui_native_clipboard_text_length() };
165 let mut bytes = vec![0u8; length as usize];
166 let copied = unsafe { fui_native_clipboard_copy(bytes.as_mut_ptr(), length) };
167 bytes.truncate(copied as usize);
168 String::from_utf8(bytes).ok()
169}
170
171#[cfg(feature = "native-runtime")]
172pub fn open_external_url(url: &str) -> bool {
173 unsafe { fui_native_open_external_url(url.as_ptr(), url.len() as u32) }
174}
175
176#[cfg(feature = "native-runtime")]
177pub fn open_file(path: impl AsRef<Path>) -> bool {
178 let Some(path) = path.as_ref().to_str() else {
179 return false;
180 };
181 unsafe { fui_native_open_file(path.as_ptr(), path.len() as u32) }
182}
183
184#[cfg(feature = "native-runtime")]
185pub fn reveal_file(path: impl AsRef<Path>) -> bool {
186 let Some(path) = path.as_ref().to_str() else {
187 return false;
188 };
189 unsafe { fui_native_reveal_file(path.as_ptr(), path.len() as u32) }
190}
191
192#[cfg(feature = "native-runtime")]
193fn show_native_file_dialog(
194 kind: u32,
195 options: NativeFileDialogOptions,
196 callback: impl FnOnce(NativeFileDialogResult) + 'static,
197) -> Option<NativeFileDialogRequest> {
198 let request_id = NEXT_NATIVE_FILE_DIALOG_ID.fetch_add(1, Ordering::Relaxed);
199 let mut encoded_filters = Vec::new();
200 for filter in &options.filters {
201 if filter.name.is_empty() || filter.extensions.is_empty() {
202 return None;
203 }
204 encoded_filters.extend_from_slice(filter.name.as_bytes());
205 encoded_filters.push(0);
206 encoded_filters.extend_from_slice(filter.extensions.join(";").as_bytes());
207 encoded_filters.push(0);
208 }
209 let default_location = options
210 .default_location
211 .as_ref()
212 .and_then(|path| path.to_str())
213 .unwrap_or_default();
214 NATIVE_FILE_DIALOG_CALLBACKS.with(|callbacks| {
215 callbacks
216 .borrow_mut()
217 .insert(request_id, Box::new(callback));
218 });
219 let shown = unsafe {
220 fui_native_show_file_dialog(
221 kind,
222 request_id,
223 encoded_filters.as_ptr(),
224 encoded_filters.len() as u32,
225 default_location.as_ptr(),
226 default_location.len() as u32,
227 options.allow_multiple,
228 )
229 };
230 if !shown {
231 NATIVE_FILE_DIALOG_CALLBACKS.with(|callbacks| {
232 callbacks.borrow_mut().remove(&request_id);
233 });
234 return None;
235 }
236 Some(NativeFileDialogRequest { request_id })
237}
238
239#[cfg(feature = "native-runtime")]
240pub fn show_open_file_dialog(
241 options: NativeFileDialogOptions,
242 callback: impl FnOnce(NativeFileDialogResult) + 'static,
243) -> Option<NativeFileDialogRequest> {
244 show_native_file_dialog(0, options, callback)
245}
246
247#[cfg(feature = "native-runtime")]
248pub fn show_save_file_dialog(
249 options: NativeFileDialogOptions,
250 callback: impl FnOnce(NativeFileDialogResult) + 'static,
251) -> Option<NativeFileDialogRequest> {
252 show_native_file_dialog(1, options, callback)
253}
254
255#[cfg(feature = "native-runtime")]
256pub fn show_open_folder_dialog(
257 options: NativeFileDialogOptions,
258 callback: impl FnOnce(NativeFileDialogResult) + 'static,
259) -> Option<NativeFileDialogRequest> {
260 show_native_file_dialog(2, options, callback)
261}
262
263#[cfg(feature = "native-runtime")]
264#[no_mangle]
268pub unsafe extern "C" fn __fui_complete_native_file_dialog(
269 request_id: u64,
270 status: u32,
271 payload: *const u8,
272 payload_length: u32,
273 selected_filter: i32,
274) -> bool {
275 let callback =
276 NATIVE_FILE_DIALOG_CALLBACKS.with(|callbacks| callbacks.borrow_mut().remove(&request_id));
277 let Some(callback) = callback else {
278 return false;
279 };
280 let bytes = if payload.is_null() || payload_length == 0 {
281 &[][..]
282 } else {
283 unsafe { std::slice::from_raw_parts(payload, payload_length as usize) }
284 };
285 let result = match status {
286 0 => NativeFileDialogResult::Selected {
287 paths: bytes
288 .split(|byte| *byte == 0)
289 .filter(|path| !path.is_empty())
290 .filter_map(|path| std::str::from_utf8(path).ok())
291 .map(PathBuf::from)
292 .collect(),
293 selected_filter: usize::try_from(selected_filter).ok(),
294 },
295 1 => NativeFileDialogResult::Cancelled,
296 _ => NativeFileDialogResult::Error(String::from_utf8_lossy(bytes).into_owned()),
297 };
298 callback(result);
299 true
300}
301
302#[cfg(feature = "native-runtime")]
303#[no_mangle]
304pub extern "C" fn __fui_clear_native_file_dialog_callbacks() {
305 NATIVE_FILE_DIALOG_CALLBACKS.with(|callbacks| callbacks.borrow_mut().clear());
306}
307
308#[cfg(feature = "native-runtime")]
309#[no_mangle]
310pub extern "C" fn __fui_run_ui_dispatch(callback_id: u64) -> bool {
311 let callback =
312 UI_DISPATCH_CALLBACKS.with(|callbacks| callbacks.borrow_mut().remove(&callback_id));
313 let Some(callback) = callback else {
314 return false;
315 };
316 callback();
317 true
318}
319
320#[cfg(feature = "native-runtime")]
321#[no_mangle]
322pub extern "C" fn __fui_cancel_ui_dispatch(callback_id: u64) {
323 UI_DISPATCH_CALLBACKS.with(|callbacks| {
324 callbacks.borrow_mut().remove(&callback_id);
325 });
326}
327
328#[cfg(feature = "native-runtime")]
329#[no_mangle]
330pub extern "C" fn __fui_clear_ui_dispatches() {
331 UI_DISPATCH_CALLBACKS.with(|callbacks| callbacks.borrow_mut().clear());
332}
333
334const KNOWN_HOST_CAPABILITIES: u32 = HostCapability::BrowserHistory as u32
335 | HostCapability::Reload as u32
336 | HostCapability::NewBrowsingContext as u32
337 | HostCapability::OpenExternalUri as u32
338 | HostCapability::ClipboardRead as u32
339 | HostCapability::ClipboardWrite as u32
340 | HostCapability::FileDialogs as u32;
341
342#[derive(Clone, Copy, Debug, PartialEq, Eq)]
343pub struct HostContext {
344 pub platform_family: PlatformFamily,
345 pub environment: HostEnvironment,
346 capabilities: u32,
347}
348
349impl HostContext {
350 pub fn new(
351 platform_family: PlatformFamily,
352 environment: HostEnvironment,
353 capabilities: u32,
354 ) -> Self {
355 Self {
356 platform_family,
357 environment,
358 capabilities: capabilities & KNOWN_HOST_CAPABILITIES,
359 }
360 }
361
362 pub fn supports(self, capability: HostCapability) -> bool {
363 (self.capabilities & capability as u32) != 0
364 }
365}
366
367pub fn device_pixel_ratio() -> f32 {
368 unsafe { ffi::get_device_pixel_ratio() }
369}
370
371pub fn platform_family() -> PlatformFamily {
372 match framework_host_services::fui_get_platform_family() {
373 1 => PlatformFamily::Apple,
374 2 => PlatformFamily::Windows,
375 3 => PlatformFamily::Linux,
376 _ => PlatformFamily::Unknown,
377 }
378}
379
380pub fn host_environment() -> HostEnvironment {
381 match framework_host_services::fui_get_host_environment() {
382 1 => HostEnvironment::Browser,
383 2 => HostEnvironment::Desktop,
384 3 => HostEnvironment::Headless,
385 _ => HostEnvironment::Unknown,
386 }
387}
388
389pub fn host_context() -> HostContext {
390 HostContext::new(
391 platform_family(),
392 host_environment(),
393 framework_host_services::fui_get_host_capabilities(),
394 )
395}
396
397pub fn has_host_capability(capability: HostCapability) -> bool {
398 host_context().supports(capability)
399}
400
401pub fn is_coarse_pointer() -> bool {
402 framework_host_services::fui_is_coarse_pointer()
403}
404
405pub fn primary_shortcut_modifier() -> u32 {
406 match platform_family() {
407 PlatformFamily::Apple => ffi::KeyModifier::Meta as u32,
408 _ => ffi::KeyModifier::Ctrl as u32,
409 }
410}
411
412pub fn word_navigation_modifier() -> u32 {
413 match platform_family() {
414 PlatformFamily::Apple => ffi::KeyModifier::Alt as u32,
415 _ => ffi::KeyModifier::Ctrl as u32,
416 }
417}
418
419pub fn line_boundary_modifier() -> u32 {
420 match platform_family() {
421 PlatformFamily::Apple => ffi::KeyModifier::Meta as u32,
422 _ => 0,
423 }
424}
425
426pub fn document_boundary_modifier() -> u32 {
427 match platform_family() {
428 PlatformFamily::Apple => ffi::KeyModifier::Meta as u32,
429 _ => ffi::KeyModifier::Ctrl as u32,
430 }
431}
432
433fn has_modifier(modifiers: u32, expected: u32) -> bool {
434 expected != 0 && (modifiers & expected) != 0
435}
436
437pub fn has_primary_shortcut_modifier(modifiers: u32) -> bool {
438 has_modifier(modifiers, primary_shortcut_modifier())
439}
440
441pub fn has_word_navigation_modifier(modifiers: u32) -> bool {
442 has_modifier(modifiers, word_navigation_modifier())
443}
444
445pub fn has_line_boundary_modifier(modifiers: u32) -> bool {
446 has_modifier(modifiers, line_boundary_modifier())
447}
448
449pub fn has_document_boundary_modifier(modifiers: u32) -> bool {
450 has_modifier(modifiers, document_boundary_modifier())
451}
452
453fn format_shortcut_key_token(key: &str, platform_family: PlatformFamily) -> String {
454 match key {
455 "ArrowLeft" => {
456 if platform_family == PlatformFamily::Apple {
457 "←".to_string()
458 } else {
459 "Left".to_string()
460 }
461 }
462 "ArrowRight" => {
463 if platform_family == PlatformFamily::Apple {
464 "→".to_string()
465 } else {
466 "Right".to_string()
467 }
468 }
469 "ArrowUp" => {
470 if platform_family == PlatformFamily::Apple {
471 "↑".to_string()
472 } else {
473 "Up".to_string()
474 }
475 }
476 "ArrowDown" => {
477 if platform_family == PlatformFamily::Apple {
478 "↓".to_string()
479 } else {
480 "Down".to_string()
481 }
482 }
483 "PageUp" => "PgUp".to_string(),
484 "PageDown" => "PgDn".to_string(),
485 _ if key.chars().count() == 1 => key.to_uppercase(),
486 _ => key.to_string(),
487 }
488}
489
490fn append_shortcut_modifier_tokens(
491 tokens: &mut Vec<String>,
492 modifiers: u32,
493 platform: PlatformFamily,
494) {
495 if platform == PlatformFamily::Apple {
496 if (modifiers & ffi::KeyModifier::Ctrl as u32) != 0 {
497 tokens.push("⌃".to_string());
498 }
499 if (modifiers & ffi::KeyModifier::Alt as u32) != 0 {
500 tokens.push("⌥".to_string());
501 }
502 if (modifiers & ffi::KeyModifier::Shift as u32) != 0 {
503 tokens.push("⇧".to_string());
504 }
505 if (modifiers & ffi::KeyModifier::Meta as u32) != 0 {
506 tokens.push("⌘".to_string());
507 }
508 return;
509 }
510
511 if (modifiers & ffi::KeyModifier::Ctrl as u32) != 0 {
512 tokens.push("Ctrl".to_string());
513 }
514 if (modifiers & ffi::KeyModifier::Alt as u32) != 0 {
515 tokens.push("Alt".to_string());
516 }
517 if (modifiers & ffi::KeyModifier::Shift as u32) != 0 {
518 tokens.push("Shift".to_string());
519 }
520 if (modifiers & ffi::KeyModifier::Meta as u32) != 0 {
521 tokens.push("Meta".to_string());
522 }
523}
524
525pub fn format_shortcut_label(key: &str, modifiers: u32) -> String {
526 let platform = platform_family();
527 let mut tokens = Vec::new();
528 append_shortcut_modifier_tokens(&mut tokens, modifiers, platform);
529 tokens.push(format_shortcut_key_token(key, platform));
530 if platform == PlatformFamily::Apple {
531 tokens.join("")
532 } else {
533 tokens.join("+")
534 }
535}
536
537pub fn format_primary_shortcut_label(key: &str) -> String {
538 format_shortcut_label(key, primary_shortcut_modifier())
539}
540
541pub fn format_undo_shortcut_label() -> String {
542 format_primary_shortcut_label("z")
543}
544
545pub fn format_redo_shortcut_label() -> String {
546 match platform_family() {
547 PlatformFamily::Apple => format_shortcut_label(
548 "z",
549 primary_shortcut_modifier() | ffi::KeyModifier::Shift as u32,
550 ),
551 _ => format_primary_shortcut_label("y"),
552 }
553}
554
555fn matches_shortcut_key(key: &str, expected: &str) -> bool {
556 key.eq_ignore_ascii_case(expected)
557}
558
559pub fn is_undo_shortcut(key: &str, modifiers: u32) -> bool {
560 (modifiers & ffi::KeyModifier::Shift as u32) == 0
561 && has_primary_shortcut_modifier(modifiers)
562 && matches_shortcut_key(key, "z")
563}
564
565pub fn is_redo_shortcut(key: &str, modifiers: u32) -> bool {
566 match platform_family() {
567 PlatformFamily::Apple => {
568 has_primary_shortcut_modifier(modifiers)
569 && (modifiers & ffi::KeyModifier::Shift as u32) != 0
570 && matches_shortcut_key(key, "z")
571 }
572 _ => has_primary_shortcut_modifier(modifiers) && matches_shortcut_key(key, "y"),
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::{
579 document_boundary_modifier, has_host_capability, host_context, host_environment,
580 is_coarse_pointer, is_redo_shortcut, is_undo_shortcut, line_boundary_modifier,
581 platform_family, HostCapability, HostContext, HostEnvironment, PlatformFamily,
582 };
583 use crate::ffi;
584
585 #[test]
586 fn returns_mock_device_pixel_ratio() {
587 ffi::test::reset();
588 ffi::test::set_device_pixel_ratio(2.5);
589 assert_eq!(super::device_pixel_ratio(), 2.5);
590 }
591
592 #[test]
593 fn reports_platform_family_and_pointer_mode() {
594 ffi::test::reset();
595 ffi::test::set_platform_family(1);
596 ffi::test::set_coarse_pointer(true);
597 assert_eq!(platform_family(), PlatformFamily::Apple);
598 assert!(is_coarse_pointer());
599 assert_eq!(line_boundary_modifier(), ffi::KeyModifier::Meta as u32);
600 assert_eq!(document_boundary_modifier(), ffi::KeyModifier::Meta as u32);
601 assert!(is_undo_shortcut("z", ffi::KeyModifier::Meta as u32));
602 assert!(is_redo_shortcut(
603 "z",
604 ffi::KeyModifier::Meta as u32 | ffi::KeyModifier::Shift as u32
605 ));
606 }
607
608 #[test]
609 fn reports_and_sanitizes_host_context() {
610 ffi::test::reset();
611 ffi::test::set_platform_family(1);
612 ffi::test::set_host_environment(2);
613 ffi::test::set_host_capabilities(
614 HostCapability::OpenExternalUri as u32
615 | HostCapability::FileDialogs as u32
616 | 0x8000_0000,
617 );
618 assert_eq!(host_environment(), HostEnvironment::Desktop);
619 assert_eq!(host_context().platform_family, PlatformFamily::Apple);
620 assert!(has_host_capability(HostCapability::OpenExternalUri));
621 assert!(has_host_capability(HostCapability::FileDialogs));
622 assert!(!has_host_capability(HostCapability::Reload));
623
624 ffi::test::set_host_environment(99);
625 assert_eq!(host_environment(), HostEnvironment::Unknown);
626 assert!(!HostContext::new(
627 PlatformFamily::Windows,
628 HostEnvironment::Desktop,
629 0x8000_0000,
630 )
631 .supports(HostCapability::BrowserHistory));
632
633 let windows_browser =
634 HostContext::new(PlatformFamily::Windows, HostEnvironment::Browser, 0);
635 let windows_desktop =
636 HostContext::new(PlatformFamily::Windows, HostEnvironment::Desktop, 0);
637 assert_eq!(
638 windows_browser.platform_family,
639 windows_desktop.platform_family
640 );
641 assert_ne!(windows_browser.environment, windows_desktop.environment);
642 }
643}