facett-core 0.1.10

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
}

// ── cross-instance state envelope (copy/paste BETWEEN same-component instances) ──
//
// A DISTINCT layer over the text clipboard: copy-component places a tagged JSON
// envelope on the OS clipboard (as text, so it survives the round-trip + even
// pastes legibly), and paste-component only adopts it when the embedded `kind`
// matches the target facet. See `.nornir/design/copy-paste-between-instances.md`.

/// The envelope key carrying the component-type tag.
const ENVELOPE_KIND: &str = "facett.kind";
/// The current envelope schema version. Bump only on an incompatible shape change;
/// [`decode_component`] rejects any other `v` cleanly (no panic, no partial state).
pub const ENVELOPE_VERSION: u64 = 1;

/// Encode `state` (a [`Facet::portable_state`](crate::Facet::portable_state)
/// value) into the tagged clipboard envelope for component type `kind`:
///
/// ```json
/// { "facett.kind": "graphpan", "v": 1, "state": { …portable_state… } }
/// ```
///
/// The result is plain text destined for the OS clipboard via [`put`].
pub fn encode_component(kind: &str, state: &serde_json::Value) -> String {
    let env = serde_json::json!({
        ENVELOPE_KIND: kind,
        "v": ENVELOPE_VERSION,
        "state": state,
    });
    // `to_string` is infallible for a value we just built from JSON primitives.
    serde_json::to_string(&env).unwrap_or_default()
}

/// Decode a clipboard `text` produced by [`encode_component`] into its
/// `(kind, state)` pair. Returns `None` if the text is not a facett component
/// envelope, or its version is not [`ENVELOPE_VERSION`] (the **version guard** —
/// an unknown `v` is rejected cleanly, never panics). Arbitrary text (a plain
/// TSV copy, a user's paste of prose) decodes to `None`, so a stray paste is a
/// no-op rather than a crash.
pub fn decode_component(text: &str) -> Option<(String, serde_json::Value)> {
    let env: serde_json::Value = serde_json::from_str(text).ok()?;
    let obj = env.as_object()?;
    // Version guard FIRST: an envelope with the wrong `v` is rejected outright.
    if obj.get("v").and_then(|v| v.as_u64())? != ENVELOPE_VERSION {
        return None;
    }
    let kind = obj.get(ENVELOPE_KIND)?.as_str()?.to_string();
    // An empty kind never round-trips (it is the opt-out floor); reject it so a
    // default-`kind()` facet can neither produce nor consume an envelope.
    if kind.is_empty() {
        return None;
    }
    let state = obj.get("state")?.clone();
    Some((kind, state))
}

#[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]);
    }

    #[test]
    fn component_envelope_round_trips_kind_and_state() {
        let state = serde_json::json!({ "pan": [3.0, 4.0], "zoom": 1.5, "selected": "b" });
        let text = encode_component("graphpan", &state);
        // The envelope is legible text (carries the tag + version + state).
        assert!(text.contains("facett.kind"));
        assert!(text.contains("graphpan"));
        let (kind, back) = decode_component(&text).expect("decodes its own envelope");
        assert_eq!(kind, "graphpan");
        assert_eq!(back, state, "state survives the round-trip byte-for-byte");
    }

    #[test]
    fn decode_rejects_unknown_version_cleanly() {
        let bad = serde_json::json!({ "facett.kind": "table", "v": 99, "state": {} }).to_string();
        assert_eq!(decode_component(&bad), None, "an unknown v is rejected, not adopted");
    }

    #[test]
    fn decode_rejects_non_envelope_text() {
        // A plain TSV copy (the text-clipboard path) is NOT a component envelope.
        assert_eq!(decode_component("name\tver\nknut\t0.1"), None);
        assert_eq!(decode_component("just some prose"), None);
        // Empty kind is the opt-out floor → never a valid envelope.
        let empty = serde_json::json!({ "facett.kind": "", "v": 1, "state": {} }).to_string();
        assert_eq!(decode_component(&empty), None);
    }
}