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    pub fn new() -> Self {
103        Self {
104            manager: arboard::Clipboard::new().expect("Failed to create clipboard"),
105        }
106    }
107
108    #[cfg(target_os = "android")]
109    /// Creates a new clipboard instance, initializing the connection to the system clipboard.
110    pub fn new(android_app: AndroidApp) -> Self {
111        Self { android_app }
112    }
113
114    /// Sets the clipboard text, overwriting any previous content.
115    ///
116    /// # Arguments
117    ///
118    /// * `text` - The string slice to be copied to the clipboard.
119    ///
120    /// # Example
121    ///
122    /// ```no_run
123    /// use tessera_ui::clipboard::Clipboard;
124    ///
125    /// let mut clipboard = Clipboard::new();
126    /// clipboard.set_text("Hello, world!");
127    /// ```
128    pub fn set_text(&mut self, text: &str) {
129        #[cfg(not(target_os = "android"))]
130        {
131            let _ = self.manager.set_text(text.to_string());
132        }
133        #[cfg(target_os = "android")]
134        {
135            set_clipboard_text(&self.android_app, text);
136        }
137    }
138
139    /// Gets the current text content from the clipboard.
140    ///
141    /// This method retrieves text from the clipboard. If the clipboard is empty, contains
142    /// non-text content, or an error occurs, it returns `None`.
143    ///
144    /// # Returns
145    ///
146    /// - `Some(String)` if text is successfully retrieved from the clipboard.
147    /// - `None` if the clipboard is empty, contains non-text data, or an error occurs.
148    ///
149    /// # Example
150    ///
151    /// ```no_run
152    /// use tessera_ui::clipboard::Clipboard;
153    ///
154    /// let mut clipboard = Clipboard::new();
155    /// clipboard.set_text("Hello, Tessera!");
156    ///
157    /// if let Some(text) = clipboard.get_text() {
158    ///     println!("Retrieved from clipboard: {}", text);
159    /// } else {
160    ///     println!("Clipboard was empty or contained non-text content.");
161    /// }
162    /// ```
163    pub fn get_text(&mut self) -> Option<String> {
164        #[cfg(not(target_os = "android"))]
165        {
166            self.manager.get_text().ok()
167        }
168        #[cfg(target_os = "android")]
169        {
170            get_clipboard_text(&self.android_app)
171        }
172    }
173
174    /// Clears the clipboard content.
175    ///
176    /// # Example
177    ///
178    /// ```no_run
179    /// use tessera_ui::clipboard::Clipboard;
180    ///
181    /// let mut clipboard = Clipboard::new();
182    /// clipboard.set_text("Temporary text"); // "Temporary text" is now in the clipboard
183    /// clipboard.clear(); // The clipboard is now cleared
184    /// ```
185    pub fn clear(&mut self) {
186        #[cfg(not(target_os = "android"))]
187        {
188            let _ = self.manager.clear();
189        }
190        #[cfg(target_os = "android")]
191        {
192            clear_clipboard(&self.android_app);
193        }
194    }
195}
196
197/// Helper function: Get ClipboardManager instance
198#[cfg(target_os = "android")]
199fn get_clipboard_manager<'a>(env: &mut JNIEnv<'a>, activity: &JObject<'a>) -> Option<JObject<'a>> {
200    // Get service using "clipboard" string directly
201    let service_name = env.new_string("clipboard").ok()?;
202    let clipboard_manager = env
203        .call_method(
204            activity,
205            "getSystemService",
206            "(Ljava/lang/String;)Ljava/lang/Object;",
207            &[JValue::from(&service_name)],
208        )
209        .ok()?
210        .l()
211        .ok()?;
212    Some(clipboard_manager)
213}
214
215/// Retrieves text from the Android system clipboard.
216///
217/// ## Parameters
218/// - `android_app`: A reference to the Android application context.
219///
220/// ## Returns
221/// - `Some(String)`: If text content is successfully read.
222/// - `None`: If the clipboard is empty, the content is not plain text, or any error occurs during the process.
223#[cfg(target_os = "android")]
224fn get_clipboard_text(android_app: &AndroidApp) -> Option<String> {
225    // 1. Get JNI environment and Activity object
226    let jvm = unsafe { jni::JavaVM::from_raw(android_app.vm_as_ptr().cast()).ok()? };
227    let mut env = jvm.attach_current_thread().ok()?;
228    let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
229
230    // 2. Get ClipboardManager
231    let clipboard_manager = get_clipboard_manager(&mut env, &activity)?;
232
233    // 3. Get Primary Clip content
234    let clip_data = env
235        .call_method(
236            &clipboard_manager,
237            "getPrimaryClip",
238            "()Landroid/content/ClipData;",
239            &[],
240        )
241        .ok()?
242        .l()
243        .ok()?;
244
245    if clip_data.is_null() {
246        return None;
247    }
248
249    let item = env
250        .call_method(
251            &clip_data,
252            "getItemAt",
253            "(I)Landroid/content/ClipData$Item;",
254            &[JValue::from(0)],
255        )
256        .ok()?
257        .l()
258        .ok()?;
259
260    if item.is_null() {
261        return None;
262    }
263
264    // 4. Use coerceToText to force convert item content to text
265    let char_seq = env
266        .call_method(
267            &item,
268            "coerceToText",
269            "(Landroid/content/Context;)Ljava/lang/CharSequence;",
270            &[JValue::from(&activity)],
271        )
272        .ok()?
273        .l()
274        .ok()?;
275
276    if char_seq.is_null() {
277        return None;
278    }
279
280    // 5. Convert CharSequence to Rust String
281    let j_string = env
282        .call_method(&char_seq, "toString", "()Ljava/lang/String;", &[])
283        .ok()?
284        .l()
285        .ok()?;
286    let rust_string: String = env.get_string(&JString::from(j_string)).ok()?.into();
287
288    Some(rust_string)
289}
290
291/// Sets text to the Android system clipboard.
292///
293/// ## Parameters
294/// - `android_app`: A reference to the Android application context.
295/// - `text`: The text to be set to the clipboard.
296#[cfg(target_os = "android")]
297fn set_clipboard_text(android_app: &AndroidApp, text: &str) {
298    let jvm = match unsafe { jni::JavaVM::from_raw(android_app.vm_as_ptr().cast()) } {
299        Ok(jvm) => jvm,
300        Err(_) => return,
301    };
302    let mut env = match jvm.attach_current_thread() {
303        Ok(env) => env,
304        Err(_) => return,
305    };
306    let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
307
308    let clipboard_manager = match get_clipboard_manager(&mut env, &activity) {
309        Some(manager) => manager,
310        None => return,
311    };
312
313    let label = match env.new_string("label") {
314        Ok(s) => s,
315        Err(_) => return,
316    };
317    let text_to_set = match env.new_string(text) {
318        Ok(s) => s,
319        Err(_) => return,
320    };
321
322    let clip_data = match env.call_static_method(
323        "android/content/ClipData",
324        "newPlainText",
325        "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;",
326        &[JValue::from(&label), JValue::from(&text_to_set)],
327    ) {
328        Ok(c) => match c.l() {
329            Ok(clip) => clip,
330            Err(_) => return,
331        },
332        Err(_) => return,
333    };
334
335    // Call setPrimaryClip, ignoring return value
336    let _ = env.call_method(
337        &clipboard_manager,
338        "setPrimaryClip",
339        "(Landroid/content/ClipData;)V",
340        &[JValue::from(&clip_data)],
341    );
342}
343
344/// Clears the Android system clipboard.
345///
346/// ## Parameters
347/// - `android_app`: A reference to the Android application context.
348#[cfg(target_os = "android")]
349fn clear_clipboard(android_app: &AndroidApp) {
350    let jvm = match unsafe { jni::JavaVM::from_raw(android_app.vm_as_ptr().cast()) } {
351        Ok(jvm) => jvm,
352        Err(_) => return,
353    };
354    let mut env = match jvm.attach_current_thread() {
355        Ok(env) => env,
356        Err(_) => return,
357    };
358    let activity = unsafe { JObject::from_raw(android_app.activity_as_ptr().cast()) };
359
360    if let Some(clipboard_manager) = get_clipboard_manager(&mut env, &activity) {
361        // Call clearPrimaryClip, ignoring return value
362        let _ = env.call_method(&clipboard_manager, "clearPrimaryClip", "()V", &[]);
363    }
364}