Skip to main content

fui/
platform.rs

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/// A one-shot, `Send` token for work whose closure remains owned by the UI thread.
109#[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    /// Keeps retained work on the UI thread and returns a token that may be sent to a worker.
143    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/// # Safety
265/// `payload` must reference at least `payload_length` readable bytes when
266/// `payload_length` is non-zero.
267#[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}