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}