1use std::sync::{Arc, Mutex, OnceLock};
15
16use arrow_array::ArrayRef;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum ClipKind {
21 Text,
22 DataColumns,
24 Rows,
26 Image,
28 Custom(&'static str),
29}
30
31#[derive(Clone)]
34pub struct ArrowColumnRef {
35 pub name: String,
36 pub array: ArrayRef,
37}
38
39impl std::fmt::Debug for ArrowColumnRef {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("ArrowColumnRef")
42 .field("name", &self.name)
43 .field("len", &self.array.len())
44 .field("dtype", &self.array.data_type())
45 .finish()
46 }
47}
48
49#[derive(Clone, Debug)]
52pub enum ClipPayload {
53 Text(String),
54 DataColumns(Vec<ArrowColumnRef>),
56 Rows(String),
58 Image(egui::ColorImage),
60 Custom { kind: &'static str, bytes: Arc<[u8]> },
62}
63
64impl ClipPayload {
65 pub fn kind(&self) -> ClipKind {
67 match self {
68 ClipPayload::Text(_) => ClipKind::Text,
69 ClipPayload::DataColumns(_) => ClipKind::DataColumns,
70 ClipPayload::Rows(_) => ClipKind::Rows,
71 ClipPayload::Image(_) => ClipKind::Image,
72 ClipPayload::Custom { kind, .. } => ClipKind::Custom(kind),
73 }
74 }
75
76 pub fn as_text(&self) -> String {
79 match self {
80 ClipPayload::Text(s) | ClipPayload::Rows(s) => s.clone(),
81 ClipPayload::DataColumns(cols) => columns_to_tsv(cols),
82 ClipPayload::Image(img) => format!("[image {}x{}]", img.size[0], img.size[1]),
83 ClipPayload::Custom { kind, bytes } => format!("[{kind}: {} bytes]", bytes.len()),
84 }
85 }
86
87 pub fn as_image(&self) -> Option<&egui::ColorImage> {
89 match self {
90 ClipPayload::Image(img) => Some(img),
91 _ => None,
92 }
93 }
94}
95
96#[cfg(feature = "os-image")]
101pub mod os_image {
102 use super::*;
103
104 pub fn put_image(img: &egui::ColorImage) -> Result<(), String> {
106 let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
107 let bytes: Vec<u8> = img.pixels.iter().flat_map(|p| [p.r(), p.g(), p.b(), p.a()]).collect();
108 cb.set_image(arboard::ImageData {
109 width: img.size[0],
110 height: img.size[1],
111 bytes: bytes.into(),
112 })
113 .map_err(|e| e.to_string())
114 }
115
116 pub fn get_image() -> Result<egui::ColorImage, String> {
118 let mut cb = arboard::Clipboard::new().map_err(|e| e.to_string())?;
119 let img = cb.get_image().map_err(|e| e.to_string())?;
120 let size = [img.width, img.height];
121 let pixels = img
122 .bytes
123 .chunks_exact(4)
124 .map(|c| egui::Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]))
125 .collect();
126 Ok(egui::ColorImage::new(size, pixels))
127 }
128}
129
130pub trait CopySource {
132 fn copy_kinds(&self) -> &[ClipKind];
134 fn copy_payload(&self) -> Option<ClipPayload>;
136}
137
138pub trait PasteTarget {
140 fn accepts(&self, k: ClipKind) -> bool;
142 fn paste_payload(&mut self, p: &ClipPayload);
144}
145
146fn registry() -> &'static Mutex<Option<ClipPayload>> {
150 static REG: OnceLock<Mutex<Option<ClipPayload>>> = OnceLock::new();
151 REG.get_or_init(|| Mutex::new(None))
152}
153
154pub fn put_typed(ctx: &egui::Context, payload: ClipPayload) {
157 let text = payload.as_text();
158 ctx.copy_text(text);
159 *registry().lock().unwrap() = Some(payload);
160}
161
162pub fn put_typed_inproc(payload: ClipPayload) {
165 *registry().lock().unwrap() = Some(payload);
166}
167
168pub fn last_typed() -> Option<ClipPayload> {
170 registry().lock().unwrap().clone()
171}
172
173pub fn clear_typed() {
175 *registry().lock().unwrap() = None;
176}
177
178pub fn paste_into(target: &mut dyn PasteTarget) -> bool {
182 let Some(payload) = last_typed() else { return false };
183 if target.accepts(payload.kind()) {
184 target.paste_payload(&payload);
185 true
186 } else if target.accepts(ClipKind::Text) {
187 target.paste_payload(&ClipPayload::Text(payload.as_text()));
188 true
189 } else {
190 false
191 }
192}
193
194pub fn columns_to_tsv(cols: &[ArrowColumnRef]) -> String {
197 if cols.is_empty() {
198 return String::new();
199 }
200 let nrows = cols.iter().map(|c| c.array.len()).max().unwrap_or(0);
201 let mut out = String::new();
202 out.push_str(&cols.iter().map(|c| c.name.as_str()).collect::<Vec<_>>().join("\t"));
203 for r in 0..nrows {
204 out.push('\n');
205 let line: Vec<String> = cols.iter().map(|c| arrow_cell_text(c.array.as_ref(), r)).collect();
206 out.push_str(&line.join("\t"));
207 }
208 out
209}
210
211fn arrow_cell_text(arr: &dyn arrow_array::Array, i: usize) -> String {
213 use arrow_array::{
214 BooleanArray, Float32Array, Float64Array, Int32Array, Int64Array, StringArray, UInt32Array, UInt64Array,
215 };
216 if i >= arr.len() || arr.is_null(i) {
217 return String::new();
218 }
219 macro_rules! tnum {
220 ($($ty:ty),*) => {{ $(if let Some(a) = arr.as_any().downcast_ref::<$ty>() { return a.value(i).to_string(); })* }};
221 }
222 tnum!(Int64Array, Int32Array, UInt64Array, UInt32Array, Float64Array, Float32Array, BooleanArray);
223 if let Some(a) = arr.as_any().downcast_ref::<StringArray>() {
224 return a.value(i).to_string();
225 }
226 String::new()
227}
228
229#[cfg(test)]
230mod tests {
231 use std::sync::Arc;
232
233 use arrow_array::{Int64Array, StringArray};
234
235 use super::*;
236
237 fn cols() -> Vec<ArrowColumnRef> {
238 vec![
239 ArrowColumnRef { name: "name".into(), array: Arc::new(StringArray::from(vec!["knut", "korp"])) },
240 ArrowColumnRef { name: "n".into(), array: Arc::new(Int64Array::from(vec![1, 2])) },
241 ]
242 }
243
244 fn registry_guard() -> std::sync::MutexGuard<'static, ()> {
250 static LOCK: Mutex<()> = Mutex::new(());
251 LOCK.lock().unwrap_or_else(|e| e.into_inner())
252 }
253
254 #[test]
255 fn datacolumns_render_as_tsv_for_the_text_fallback() {
256 let p = ClipPayload::DataColumns(cols());
257 assert_eq!(p.kind(), ClipKind::DataColumns);
258 assert_eq!(p.as_text(), "name\tn\nknut\t1\nkorp\t2");
259 }
260
261 #[test]
262 fn datacolumns_are_zero_copy_arc_shared() {
263 let original = cols();
264 let arr_ptr = Arc::as_ptr(&original[0].array) as *const ();
265 let payload = ClipPayload::DataColumns(original);
266 if let ClipPayload::DataColumns(c) = &payload {
268 assert_eq!(Arc::as_ptr(&c[0].array) as *const (), arr_ptr, "column array must be Arc-shared, not copied");
269 } else {
270 panic!("wrong kind");
271 }
272 }
273
274 struct ColTarget {
275 last: Option<usize>,
276 text: Option<String>,
277 }
278 impl PasteTarget for ColTarget {
279 fn accepts(&self, k: ClipKind) -> bool {
280 matches!(k, ClipKind::DataColumns | ClipKind::Text)
281 }
282 fn paste_payload(&mut self, p: &ClipPayload) {
283 match p {
284 ClipPayload::DataColumns(c) => self.last = Some(c.len()),
285 ClipPayload::Text(s) => self.text = Some(s.clone()),
286 _ => {}
287 }
288 }
289 }
290
291 #[test]
292 fn typed_transfer_between_components_in_process() {
293 let _guard = registry_guard();
294 clear_typed();
295 put_typed_inproc(ClipPayload::DataColumns(cols()));
297 let mut t = ColTarget { last: None, text: None };
299 assert!(paste_into(&mut t));
300 assert_eq!(t.last, Some(2), "target received 2 typed columns");
301 assert!(t.text.is_none(), "richest accepted kind used, not the text fallback");
302 clear_typed();
303 }
304
305 #[test]
306 fn image_payload_transfers_to_a_viewer_in_process() {
307 let _guard = registry_guard();
308 clear_typed();
309 let img = egui::ColorImage::new(
311 [2, 2],
312 vec![egui::Color32::RED, egui::Color32::GREEN, egui::Color32::BLUE, egui::Color32::WHITE],
313 );
314 put_typed_inproc(ClipPayload::Image(img));
315
316 struct Viewer(Option<[usize; 2]>);
317 impl PasteTarget for Viewer {
318 fn accepts(&self, k: ClipKind) -> bool {
319 matches!(k, ClipKind::Image | ClipKind::Text)
320 }
321 fn paste_payload(&mut self, p: &ClipPayload) {
322 if let Some(img) = p.as_image() {
323 self.0 = Some(img.size);
324 }
325 }
326 }
327 let mut v = Viewer(None);
328 assert!(paste_into(&mut v));
329 assert_eq!(v.0, Some([2, 2]), "viewer received the typed image (a copied frame)");
330 clear_typed();
331 }
332
333 #[test]
334 fn text_is_the_universal_fallback() {
335 let _guard = registry_guard();
336 clear_typed();
337 put_typed_inproc(ClipPayload::DataColumns(cols()));
338 struct TextOnly(String);
340 impl PasteTarget for TextOnly {
341 fn accepts(&self, k: ClipKind) -> bool {
342 matches!(k, ClipKind::Text)
343 }
344 fn paste_payload(&mut self, p: &ClipPayload) {
345 if let ClipPayload::Text(s) = p {
346 self.0 = s.clone();
347 }
348 }
349 }
350 let mut t = TextOnly(String::new());
351 assert!(paste_into(&mut t));
352 assert_eq!(t.0, "name\tn\nknut\t1\nkorp\t2", "fell back to the TSV text view");
353 clear_typed();
354 }
355}