Skip to main content

facett_core/
clip.rs

1//! **Cross-facet typed copy/paste** (§16) — the trait + payload machinery so
2//! copy/paste carries **full typed data** between facett components, not just
3//! text. A process-wide transfer registry holds the last typed [`ClipPayload`] so
4//! a paste target gets the richest representation the source offered; on copy we
5//! also mirror Text/TSV to the OS clipboard for external apps.
6//!
7//! This is the typed realisation of the contract's COH-4 ("copy/paste of data is
8//! identical everywhere"). The legacy text-only [`crate::clipboard`] route still
9//! works; this adds the *typed* lane on top.
10//!
11//! M1 ships **Text** + **DataColumns** (zero-copy Arrow via `Arc`); M2 adds
12//! **Image** + an OS image bridge (`arboard`).
13
14use std::sync::{Arc, Mutex, OnceLock};
15
16use arrow_array::ArrayRef;
17
18/// The kinds a payload can take. `Custom` is the extension point.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum ClipKind {
21    Text,
22    /// Typed columns (Arrow arrays), zero-copy via `Arc`.
23    DataColumns,
24    /// A rectangle of rows as TSV (Excel/Sheets-pasteable).
25    Rows,
26    /// An image (M2).
27    Image,
28    Custom(&'static str),
29}
30
31/// One named Arrow column, shared zero-copy. The `Arc<dyn Array>` is the same
32/// buffer the source holds — copying a grid column never clones the data.
33#[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/// A typed clipboard payload. The richest form a source can offer; a target picks
50/// the best representation it accepts (negotiation in [`PasteTarget::paste`]).
51#[derive(Clone, Debug)]
52pub enum ClipPayload {
53    Text(String),
54    /// Arc-shared Arrow columns — zero-copy.
55    DataColumns(Vec<ArrowColumnRef>),
56    /// TSV rows (a tabular text mirror).
57    Rows(String),
58    /// An RGBA image (e.g. a video frame or a chart export) — M2 (CLIP-4).
59    Image(egui::ColorImage),
60    /// Raw custom bytes under a kind tag.
61    Custom { kind: &'static str, bytes: Arc<[u8]> },
62}
63
64impl ClipPayload {
65    /// Which kind this payload is.
66    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    /// The **universal fallback** text view of any payload (CLIP-3). DataColumns
77    /// render as TSV; Rows/Text pass through; Custom yields a lossy length tag.
78    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    /// The image, if this payload is one.
88    pub fn as_image(&self) -> Option<&egui::ColorImage> {
89        match self {
90            ClipPayload::Image(img) => Some(img),
91            _ => None,
92        }
93    }
94}
95
96/// **OS image clipboard bridge** (CLIP-5). egui's OS clipboard is text-only; this
97/// optionally uses `arboard` (feature `os-image`) to put/get an image on the real
98/// OS clipboard. In-process typed transfer (the registry) works regardless, so
99/// this is purely the cross-application lane.
100#[cfg(feature = "os-image")]
101pub mod os_image {
102    use super::*;
103
104    /// Place an egui [`egui::ColorImage`] on the OS clipboard via arboard.
105    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    /// Read an image off the OS clipboard into an egui [`egui::ColorImage`].
117    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
130/// A component that can **produce** a typed payload from its current selection.
131pub trait CopySource {
132    /// The kinds this source can offer, richest first.
133    fn copy_kinds(&self) -> &[ClipKind];
134    /// Produce the payload for the current selection (richest representation).
135    fn copy_payload(&self) -> Option<ClipPayload>;
136}
137
138/// A component that can **accept** a pasted payload.
139pub trait PasteTarget {
140    /// Does this target accept kind `k`?
141    fn accepts(&self, k: ClipKind) -> bool;
142    /// Apply the payload. Text is the universal fallback (CLIP-3).
143    fn paste_payload(&mut self, p: &ClipPayload);
144}
145
146/// Process-wide transfer registry (CLIP-2): the last typed payload a copy
147/// produced. A paste target reads this to get the richest representation, even
148/// across components that never touch the OS clipboard.
149fn registry() -> &'static Mutex<Option<ClipPayload>> {
150    static REG: OnceLock<Mutex<Option<ClipPayload>>> = OnceLock::new();
151    REG.get_or_init(|| Mutex::new(None))
152}
153
154/// Store the typed payload for the next paste, and mirror **Text/TSV** to the OS
155/// clipboard via egui (CLIP-2) so external apps (Excel/editors) can paste too.
156pub 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
162/// Store the typed payload without an egui context (tests / headless in-process
163/// transfer between facett components).
164pub fn put_typed_inproc(payload: ClipPayload) {
165    *registry().lock().unwrap() = Some(payload);
166}
167
168/// The last typed payload, if any (CLIP-2 read side).
169pub fn last_typed() -> Option<ClipPayload> {
170    registry().lock().unwrap().clone()
171}
172
173/// Clear the registry (tests).
174pub fn clear_typed() {
175    *registry().lock().unwrap() = None;
176}
177
178/// **Negotiated paste** (CLIP-3): hand `target` the best kind it accepts from the
179/// last typed payload. If the payload's own kind isn't accepted, fall back to a
180/// Text view (the universal fallback). Returns true if a paste happened.
181pub 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
194/// Render Arrow columns to TSV (header + tab-joined cells) — the spreadsheet
195/// mirror used by [`ClipPayload::as_text`] and the OS-clipboard mirror.
196pub 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
211/// Best-effort text of one Arrow cell (common primitive types).
212fn 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    #[test]
245    fn datacolumns_render_as_tsv_for_the_text_fallback() {
246        let p = ClipPayload::DataColumns(cols());
247        assert_eq!(p.kind(), ClipKind::DataColumns);
248        assert_eq!(p.as_text(), "name\tn\nknut\t1\nkorp\t2");
249    }
250
251    #[test]
252    fn datacolumns_are_zero_copy_arc_shared() {
253        let original = cols();
254        let arr_ptr = Arc::as_ptr(&original[0].array) as *const ();
255        let payload = ClipPayload::DataColumns(original);
256        // The payload holds the SAME Arc, not a clone of the buffer.
257        if let ClipPayload::DataColumns(c) = &payload {
258            assert_eq!(Arc::as_ptr(&c[0].array) as *const (), arr_ptr, "column array must be Arc-shared, not copied");
259        } else {
260            panic!("wrong kind");
261        }
262    }
263
264    struct ColTarget {
265        last: Option<usize>,
266        text: Option<String>,
267    }
268    impl PasteTarget for ColTarget {
269        fn accepts(&self, k: ClipKind) -> bool {
270            matches!(k, ClipKind::DataColumns | ClipKind::Text)
271        }
272        fn paste_payload(&mut self, p: &ClipPayload) {
273            match p {
274                ClipPayload::DataColumns(c) => self.last = Some(c.len()),
275                ClipPayload::Text(s) => self.text = Some(s.clone()),
276                _ => {}
277            }
278        }
279    }
280
281    #[test]
282    fn typed_transfer_between_components_in_process() {
283        clear_typed();
284        // Source "copies" two typed columns.
285        put_typed_inproc(ClipPayload::DataColumns(cols()));
286        // Target accepts DataColumns → gets the typed payload, not text.
287        let mut t = ColTarget { last: None, text: None };
288        assert!(paste_into(&mut t));
289        assert_eq!(t.last, Some(2), "target received 2 typed columns");
290        assert!(t.text.is_none(), "richest accepted kind used, not the text fallback");
291        clear_typed();
292    }
293
294    #[test]
295    fn image_payload_transfers_to_a_viewer_in_process() {
296        clear_typed();
297        // A "video frame": a 2x2 RGBA image.
298        let img = egui::ColorImage::new(
299            [2, 2],
300            vec![egui::Color32::RED, egui::Color32::GREEN, egui::Color32::BLUE, egui::Color32::WHITE],
301        );
302        put_typed_inproc(ClipPayload::Image(img));
303
304        struct Viewer(Option<[usize; 2]>);
305        impl PasteTarget for Viewer {
306            fn accepts(&self, k: ClipKind) -> bool {
307                matches!(k, ClipKind::Image | ClipKind::Text)
308            }
309            fn paste_payload(&mut self, p: &ClipPayload) {
310                if let Some(img) = p.as_image() {
311                    self.0 = Some(img.size);
312                }
313            }
314        }
315        let mut v = Viewer(None);
316        assert!(paste_into(&mut v));
317        assert_eq!(v.0, Some([2, 2]), "viewer received the typed image (a copied frame)");
318        clear_typed();
319    }
320
321    #[test]
322    fn text_is_the_universal_fallback() {
323        clear_typed();
324        put_typed_inproc(ClipPayload::DataColumns(cols()));
325        // A target that only accepts Text still pastes (gets the TSV view).
326        struct TextOnly(String);
327        impl PasteTarget for TextOnly {
328            fn accepts(&self, k: ClipKind) -> bool {
329                matches!(k, ClipKind::Text)
330            }
331            fn paste_payload(&mut self, p: &ClipPayload) {
332                if let ClipPayload::Text(s) = p {
333                    self.0 = s.clone();
334                }
335            }
336        }
337        let mut t = TextOnly(String::new());
338        assert!(paste_into(&mut t));
339        assert_eq!(t.0, "name\tn\nknut\t1\nkorp\t2", "fell back to the TSV text view");
340        clear_typed();
341    }
342}