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}