cranpose-ui 0.1.57

UI primitives for Cranpose
Documentation
//! Platform clipboard session: an OS clipboard read/write bridge for in-tree UI.
//!
//! The text selection contextual menu (Copy / Cut / Paste) lives in the widget
//! tree (`cranpose-ui`), which cannot reach the OS clipboard directly — that
//! machinery lives one layer up in `cranpose-app-shell` / platform glue
//! (`arboard` on desktop, DOM clipboard on web). Platforms install a
//! [`PlatformClipboard`] so the menu can copy/paste through the system
//! clipboard; when none is installed an in-process fallback keeps copy/paste
//! working within the app (and in headless tests).
//!
//! The session is stored per [`AppContext`](crate::render_state::AppContext),
//! like the text-input and focus sessions, so multiple app instances in one
//! process do not share a clipboard.

use std::cell::RefCell;
use std::rc::Rc;

/// A platform-provided OS clipboard. Installed by the platform runtime so that
/// in-tree UI (the selection menu) can read/write the system clipboard.
pub trait PlatformClipboard {
    /// Writes `text` to the OS clipboard.
    fn write_text(&self, text: &str);
    /// Reads the OS clipboard's text, or `None` when empty/unavailable.
    fn read_text(&self) -> Option<String>;
}

/// Per-app-context clipboard state: an optionally-installed platform clipboard
/// plus an in-process fallback used when none is installed.
pub(crate) struct ClipboardSessionState {
    platform: RefCell<Option<Rc<dyn PlatformClipboard>>>,
    fallback: RefCell<Option<String>>,
}

impl ClipboardSessionState {
    pub(crate) fn new() -> Self {
        Self {
            platform: RefCell::new(None),
            fallback: RefCell::new(None),
        }
    }

    fn set_platform(&self, clipboard: Option<Rc<dyn PlatformClipboard>>) {
        *self.platform.borrow_mut() = clipboard;
    }

    fn write(&self, text: &str) {
        if let Some(platform) = self.platform.borrow().clone() {
            platform.write_text(text);
        } else {
            *self.fallback.borrow_mut() = Some(text.to_string());
        }
    }

    fn read(&self) -> Option<String> {
        if let Some(platform) = self.platform.borrow().clone() {
            platform.read_text()
        } else {
            self.fallback.borrow().clone()
        }
    }

    fn has_platform(&self) -> bool {
        self.platform.borrow().is_some()
    }
}

/// Installs the platform OS clipboard for the current app context, replacing any
/// previously installed one. Platform runtimes call this
/// (`AppShell::set_platform_clipboard`).
pub fn set_platform_clipboard(clipboard: Rc<dyn PlatformClipboard>) {
    crate::render_state::with_clipboard_session(|state| state.set_platform(Some(clipboard)));
}

/// Removes the installed platform clipboard, falling back to the in-process one.
pub fn clear_platform_clipboard() {
    crate::render_state::with_clipboard_session(|state| state.set_platform(None));
}

/// Writes `text` to the clipboard (OS clipboard when a platform is installed,
/// otherwise the in-process fallback).
pub fn clipboard_write_text(text: &str) {
    crate::render_state::with_clipboard_session(|state| state.write(text));
}

/// Reads the clipboard's text, or `None` when empty/unavailable.
pub fn clipboard_read_text() -> Option<String> {
    crate::render_state::with_clipboard_session(|state| state.read())
}

/// Whether a real OS clipboard is installed for the current app context (as
/// opposed to the in-process fallback used in headless tests or on platforms
/// with no clipboard backend registered).
pub fn has_platform_clipboard() -> bool {
    crate::render_state::with_clipboard_session(|state| state.has_platform())
}

/// A Compose-style handle to the system clipboard — the framework analogue of
/// Jetpack Compose's `LocalClipboardManager`. Obtain it from
/// [`local_clipboard`] during composition, then read/write it (typically from an
/// event handler):
///
/// ```ignore
/// let clipboard = local_clipboard().current();
/// Button(Modifier::empty(), move || clipboard.set_text("copied!"), || Text("Copy"));
/// ```
///
/// It reads and writes through the app's clipboard session, so it targets the
/// installed platform clipboard (UIPasteboard, arboard, …) when present and an
/// in-process fallback otherwise.
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
pub struct ClipboardManager;

impl ClipboardManager {
    /// Writes `text` to the clipboard.
    pub fn set_text(&self, text: &str) {
        clipboard_write_text(text);
    }

    /// Reads the clipboard's text, or `None` when empty/unavailable.
    pub fn text(&self) -> Option<String> {
        clipboard_read_text()
    }

    /// Whether writes reach a real OS clipboard (vs the in-process fallback).
    pub fn has_system_clipboard(&self) -> bool {
        has_platform_clipboard()
    }
}

/// CompositionLocal carrying the [`ClipboardManager`]. The same instance is
/// returned on every call (cached per thread), matching `local_uri_handler` and
/// the insets locals.
pub fn local_clipboard() -> cranpose_core::CompositionLocal<ClipboardManager> {
    thread_local! {
        static LOCAL_CLIPBOARD: RefCell<Option<cranpose_core::CompositionLocal<ClipboardManager>>> =
            const { RefCell::new(None) };
    }

    LOCAL_CLIPBOARD.with(|cell| {
        cell.borrow_mut()
            .get_or_insert_with(|| cranpose_core::compositionLocalOf(ClipboardManager::default))
            .clone()
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::cell::RefCell;

    struct RecordingClipboard {
        value: RefCell<Option<String>>,
    }

    impl PlatformClipboard for RecordingClipboard {
        fn write_text(&self, text: &str) {
            *self.value.borrow_mut() = Some(text.to_string());
        }
        fn read_text(&self) -> Option<String> {
            self.value.borrow().clone()
        }
    }

    #[test]
    fn manager_uses_in_process_fallback_without_a_platform_clipboard() {
        let context = crate::render_state::AppContext::new();
        context.enter(|| {
            let clipboard = ClipboardManager;
            assert!(!clipboard.has_system_clipboard());
            clipboard.set_text("hello");
            assert_eq!(clipboard.text().as_deref(), Some("hello"));
        });
    }

    #[test]
    fn manager_routes_through_the_installed_platform_clipboard() {
        let context = crate::render_state::AppContext::new();
        context.enter(|| {
            let recorder = Rc::new(RecordingClipboard {
                value: RefCell::new(None),
            });
            set_platform_clipboard(recorder.clone());

            let clipboard = ClipboardManager;
            assert!(clipboard.has_system_clipboard());
            clipboard.set_text("world");
            assert_eq!(recorder.value.borrow().as_deref(), Some("world"));
            assert_eq!(clipboard.text().as_deref(), Some("world"));

            clear_platform_clipboard();
            assert!(!clipboard.has_system_clipboard());
        });
    }
}