tessera_ui/
clipboard.rs

1//! Provides a cross-platform clipboard manager for text manipulation.
2//!
3//! This module offers a simple, unified interface for interacting with the system clipboard,
4//! allowing applications to easily get and set text content. It abstracts platform-specific
5//! details, providing a consistent API across different operating systems.
6//!
7//! # Key Features
8//!
9//! - **Set Text**: Place a string onto the system clipboard.
10//! - **Get Text**: Retrieve the current text content from the system clipboard.
11//! - **Cross-platform**: Uses `arboard` for broad platform support (Windows, macOS, Linux).
12//! - **Graceful Fallback**: On unsupported platforms like Android, operations are no-ops
13//!   that log a warning, preventing crashes.
14//!
15//! # Usage
16//!
17//! The main entry point is the [`Clipboard`] struct, which provides methods to interact
18//! with the system clipboard.
19//!
20//! ```no_run
21//! use tessera_ui::clipboard::Clipboard;
22//!
23//! // Create a new clipboard instance.
24//! let mut clipboard = Clipboard::new();
25//!
26//! // Set text to the clipboard.
27//! let text_to_set = "Hello, Tessera!";
28//! clipboard.set_text(text_to_set);
29//!
30//! // Get text from the clipboard.
31//! if let Some(text_from_clipboard) = clipboard.get_text() {
32//!     assert_eq!(text_from_clipboard, text_to_set);
33//!     println!("Clipboard text: {}", text_from_clipboard);
34//! } else {
35//!     println!("Could not retrieve text from clipboard.");
36//! }
37//! ```
38//!
39//! # Note on Android
40//!
41//! Clipboard operations are currently not supported on Android. Any calls to `set_text` or
42//! `get_text` on Android will result in a warning log and will not perform any action.
43#[cfg(target_os = "android")]
44use jni::{
45    JNIEnv,
46    objects::{JObject, JString, JValue},
47};
48#[cfg(target_os = "android")]
49use winit::platform::android::activity::AndroidApp;
50
51/// Manages access to the system clipboard for text-based copy and paste operations.
52///
53/// This struct acts as a handle to the platform's native clipboard, abstracting away the
54/// underlying implementation details. It is created using [`Clipboard::new()`].
55///
56/// All interactions are synchronous. For unsupported platforms (e.g., Android),
57/// operations are gracefully handled to prevent runtime errors.
58pub struct Clipboard {
59    #[cfg(not(target_os = "android"))]
60    /// The clipboard manager for handling clipboard operations.
61    manager: arboard::Clipboard,
62    #[cfg(target_os = "android")]
63    android_app: AndroidApp,
64}
65
66#[cfg(not(target_os = "android"))]
67impl Default for Clipboard {
68    /// Creates a new `Clipboard` instance using default settings.
69    ///
70    /// This is equivalent to calling [`Clipboard::new()`].
71    ///
72    /// # Example
73    ///
74    /// ```no_run
75    /// use tessera_ui::clipboard::Clipboard;
76    ///
77    /// let clipboard = Clipboard::default();
78    /// ```
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl Clipboard {
85    #[cfg(not(target_os = "android"))]
86    /// Creates a new clipboard instance, initializing the connection to the system clipboard.
87    ///
88    /// This method may fail if the system clipboard is unavailable, in which case it will panic.
89    ///
90    /// # Panics
91    ///
92    /// Panics if the clipboard provider cannot be initialized. This can happen in environments
93    /// without a graphical user interface or due to system-level permission issues.
94    ///
95    /// # Example
96    ///
97    /// ```no_run
98    /// use tessera_ui::clipboard::Clipboard;
99    ///
100    /// let clipboard = Clipboard::new();
101    /// ```
102    #[must_use]
103    pub fn new() -> Self {
104        Self {
105            manager: arboard::Clipboard::new().expect("Failed to create clipboard"),
106        }
107    }
108
109    #[cfg(target_os = "android")]
110    /// Creates a new clipboard instance, initializing the connection to the system clipboard.
111    pub fn new(android_app: AndroidApp) -> Self {
112        Self { android_app }
113    }
114
115    /// Sets the clipboard text, overwriting any previous content.
116    ///
117    /// # Arguments
118    ///
119    /// * `text` - The string slice to be copied to the clipboard.
120    ///
121    /// # Example
122    ///
123    /// ```no_run
124    /// use tessera_ui::clipboard::Clipboard;
125    ///
126    /// let mut clipboard = Clipboard::new();
127    /// clipboard.set_text("Hello, world!");
128    /// ```
129    pub fn set_text(&mut self, text: &str) {
130        #[cfg(not(target_os = "android"))]
131        {
132            let _ = self.manager.set_text(text.to_string());
133        }
134        #[cfg(target_os = "android")]
135        {
136            set_clipboard_text(&self.android_app, text);
137        }
138    }
139
140    /// Gets the current text content from the clipboard.
141    ///
142    /// This method retrieves text from the clipboard. If the clipboard is empty, contains
143    /// non-text content, or an error occurs, it returns `None`.
144    ///
145    /// # Returns
146    ///
147    /// - `Some(String)` if text is successfully retrieved from the clipboard.
148    /// - `None` if the clipboard is empty, contains non-text data, or an error occurs.
149    ///
150    /// # Example
151    ///
152    /// ```no_run
153    /// use tessera_ui::clipboard::Clipboard;
154    ///
155    /// let mut clipboard = Clipboard::new();
156    /// clipboard.set_text("Hello, Tessera!");
157    ///
158    /// if let Some(text) = clipboard.get_text() {
159    ///     println!("Retrieved from clipboard: {}", text);
160    /// } else {
161    ///     println!("Clipboard was empty or contained non-text content.");
162    /// }
163    /// ```
164    pub fn get_text(&mut self) -> Option<String> {
165        #[cfg(not(target_os = "android"))]
166        {
167            self.manager.get_text().ok()
168        }
169        #[cfg(target_os = "android")]
170        {
171            get_clipboard_text(&self.android_app)
172        }
173    }
174
175    /// Clears the clipboard content.
176    ///
177    /// # Example
178    ///
179    /// ```no_run
180    /// use tessera_ui::clipboard::Clipboard;
181    ///
182    /// let mut clipboard = Clipboard::new();
183    /// clipboard.set_text("Temporary text"); // "Temporary text" is now in the clipboard
184    /// clipboard.clear(); // The clipboard is now cleared
185    /// ```
186    pub fn clear(&mut self) {
187        #[cfg(not(target_os = "android"))]
188        {
189            let _ = self.manager.clear();
190        }
191        #[cfg(target_os = "android")]
192        {
193            clear_clipboard(&self.android_app);
194        }
195    }
196}
197
198/// Helper function: Get ClipboardManager instance
199#[cfg(target_os = "android")]
200fn get_clipboard_manager<'a>(env: &mut JNIEnv<'a>, activity: &JObject<'a>) -> Option<JObject<'a>> {
201    // Get service using "clipboard" string directly
202    let service_name = env.new_string("clipboard").ok()?;
203    let clipboard_manager = env
204        .call_method(
205            activity,
206            "getSystemService",
207            "(Ljava/lang/String;)Ljava/lang/Object;",
208            &[JValue::from(&service_name)],
209        )
210        .ok()?
211        .l()
212        .ok()?;
213    Some(clipboard_manager)
214}
215
216/// Retrieves text from the Android system clipboard.
217///
218/// ## Parameters
219/// - `android_app`: A reference to the Android application context.
220///
221/// ## Returns
222/// - `Some(String)`: If text content is successfully read.
223/// - `None`: If the clipboard is empty, the content is not plain text, or any error occurs during the process.
224#[cfg(target_os = "android")]
225fn get_clipboard_text(android_app: &AndroidApp) -> Option<String> {
226    // 1. Get JNI environment and Activity object
227    let jvm = unsafe { jni::JavaVM::from_raw(android_app.vm_as_ptr().cast()).ok()? };
228    let mut env = jvm.attach_current_thread().ok()?;
229    let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
230
231    // 2. Get ClipboardManager
232    let clipboard_manager = get_clipboard_manager(&mut env, &activity)?;
233
234    // 3. Get Primary Clip content
235    let clip_data = env
236        .call_method(
237            &clipboard_manager,
238            "getPrimaryClip",
239            "()Landroid/content/ClipData;",
240            &[],
241        )
242        .ok()?
243        .l()
244        .ok()?;
245
246    if clip_data.is_null() {
247        return None;
248    }
249
250    let item = env
251        .call_method(
252            &clip_data,
253            "getItemAt",
254            "(I)Landroid/content/ClipData$Item;",
255            &[JValue::from(0)],
256        )
257        .ok()?
258        .l()
259        .ok()?;
260
261    if item.is_null() {
262        return None;
263    }
264
265    // 4. Use coerceToText to force convert item content to text
266    let char_seq = env
267        .call_method(
268            &item,
269            "coerceToText",
270            "(Landroid/content/Context;)Ljava/lang/CharSequence;",
271            &[JValue::from(&activity)],
272        )
273        .ok()?
274        .l()
275        .ok()?;
276
277    if char_seq.is_null() {
278        return None;
279    }
280
281    // 5. Convert CharSequence to Rust String
282    let j_string = env
283        .call_method(&char_seq, "toString", "()Ljava/lang/String;", &[])
284        .ok()?
285        .l()
286        .ok()?;
287    let rust_string: String = env.get_string(&JString::from(j_string)).ok()?.into();
288
289    Some(rust_string)
290}
291
292/// Sets text to the Android system clipboard.
293///
294/// ## Parameters
295/// - `android_app`: A reference to the Android application context.
296/// - `text`: The text to be set to the clipboard.
297#[cfg(target_os = "android")]
298fn set_clipboard_text(android_app: &AndroidApp, text: &str) {
299    let jvm = match unsafe { jni::JavaVM::from_raw(android_app.vm_as_ptr().cast()) } {
300        Ok(jvm) => jvm,
301        Err(_) => return,
302    };
303    let mut env = match jvm.attach_current_thread() {
304        Ok(env) => env,
305        Err(_) => return,
306    };
307    let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
308
309    let clipboard_manager = match get_clipboard_manager(&mut env, &activity) {
310        Some(manager) => manager,
311        None => return,
312    };
313
314    let label = match env.new_string("label") {
315        Ok(s) => s,
316        Err(_) => return,
317    };
318    let text_to_set = match env.new_string(text) {
319        Ok(s) => s,
320        Err(_) => return,
321    };
322
323    let clip_data = match env.call_static_method(
324        "android/content/ClipData",
325        "newPlainText",
326        "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
327        &[JValue::from(&label), JValue::from(&text_to_set)],
328    ) {
329        Ok(c) => match c.l() {
330            Ok(clip) => clip,
331            Err(_) => return,
332        },
333        Err(_) => return,
334    };
335
336    // Call setPrimaryClip, ignoring return value
337    let _ = env.call_method(
338        &clipboard_manager,
339        "setPrimaryClip",
340        "(Landroid/content/ClipData;)V",
341        &[JValue::from(&clip_data)],
342    );
343}
344
345/// Clears the Android system clipboard.
346///
347/// ## Parameters
348/// - `android_app`: A reference to the Android application context.
349#[cfg(target_os = "android")]
350fn clear_clipboard(android_app: &AndroidApp) {
351    let jvm = match unsafe { jni::JavaVM::from_raw(android_app.vm_as_ptr().cast()) } {
352        Ok(jvm) => jvm,
353        Err(_) => return,
354    };
355    let mut env = match jvm.attach_current_thread() {
356        Ok(env) => env,
357        Err(_) => return,
358    };
359    let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
360
361    if let Some(clipboard_manager) = get_clipboard_manager(&mut env, &activity) {
362        // Call clearPrimaryClip, ignoring return value
363        let _ = env.call_method(&clipboard_manager, "clearPrimaryClip", "()V", &[]);
364    }
365}