facett-core 0.1.0

facett — visual kernel: render a node/edge Scene into egui (wgpu fast path to come)
Documentation
//! **Uniform clipboard** — cut / copy / paste routed the same way for every
//! [`Facet`](crate::Facet). egui delivers the OS clipboard *gesture* as semantic
//! events ([`egui::Event::Copy`]/[`Cut`](egui::Event::Cut)/[`Paste`](egui::Event::Paste)),
//! which a focused `TextEdit` consumes first — so a form field handles its own
//! copy and the facet-level [`copy`](crate::Facet::copy) only fires when the
//! facet body is the clipboard target. The deck drains these events once per
//! frame ([`poll`]) and makes the single OS-touching write ([`put`]); facets
//! stay pure (`copy`/`cut` *return* the text). See `.nornir/design/clipboard.md`.

/// The three clipboard intents, decoded from egui events for the host.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClipAction {
    Copy,
    Cut,
    Paste(String),
}

/// Drain this frame's clipboard events from the context, in order.
/// The deck calls this once per frame and routes each action to the active facet.
pub fn poll(ctx: &egui::Context) -> Vec<ClipAction> {
    ctx.input(|i| {
        i.events
            .iter()
            .filter_map(|e| match e {
                egui::Event::Copy => Some(ClipAction::Copy),
                egui::Event::Cut => Some(ClipAction::Cut),
                egui::Event::Paste(s) => Some(ClipAction::Paste(s.clone())),
                _ => None,
            })
            .collect()
    })
}

/// Place `text` on the OS clipboard (thin wrapper for symmetry + testability).
/// This is the single OS-touching call — facets never call it themselves.
pub fn put(ctx: &egui::Context, text: impl Into<String>) {
    ctx.copy_text(text.into());
}

/// Build a TSV blob from a header + rows — the convention for tabular `copy()`
/// payloads (header row + selected rows, `\t`-joined, `\n` between rows). Shared
/// so `facett-table` and any other row-oriented facet serialize identically.
pub fn rows_to_tsv<S: AsRef<str>>(header: &[S], rows: impl IntoIterator<Item = Vec<String>>) -> String {
    let mut out = String::new();
    if !header.is_empty() {
        out.push_str(&header.iter().map(|c| c.as_ref()).collect::<Vec<_>>().join("\t"));
    }
    for row in rows {
        if !out.is_empty() {
            out.push('\n');
        }
        out.push_str(&row.join("\t"));
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn rows_to_tsv_joins_header_and_rows() {
        let tsv = rows_to_tsv(
            &["name", "ver"],
            vec![vec!["knut".to_string(), "0.1".to_string()], vec!["korp".to_string(), "0.2".to_string()]],
        );
        assert_eq!(tsv, "name\tver\nknut\t0.1\nkorp\t0.2");
    }

    #[test]
    fn rows_to_tsv_no_header() {
        let tsv = rows_to_tsv::<&str>(&[], vec![vec!["a".to_string(), "b".to_string()]]);
        assert_eq!(tsv, "a\tb");
    }

    #[test]
    fn poll_decodes_events_in_order() {
        let ctx = egui::Context::default();
        let input = egui::RawInput {
            events: vec![
                egui::Event::Copy,
                egui::Event::Paste("hello".to_string()),
                egui::Event::Cut,
            ],
            ..Default::default()
        };
        // Drain inside a frame so the events are live on the context.
        let mut got = Vec::new();
        let _ = ctx.run(input, |ctx| {
            got = poll(ctx);
        });
        assert_eq!(got, vec![ClipAction::Copy, ClipAction::Paste("hello".to_string()), ClipAction::Cut]);
    }
}