facett-core/src/clipboard.rs
//! **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();
ctx.run_ui(input, |_ui| {
got = poll(&ctx);
}).textures_delta.clear();
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);
}
}