gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Fixtures cut or copied in the topology, as text on the system clipboard.

use crate::fixture_editor::state::EditorState;
use gled_model::patching::Patch;
use gled_model::patching::fixture::{Fixture, FixtureId, LayoutPosition};
use gled_model::patching::layout::LayoutRef;

/// Fixtures on the clipboard, as text.
///
/// The system clipboard is what Cmd+V reads, and egui only reports a paste
/// when it holds something — so a copy has to put the fixtures there, not just
/// in the editor.
#[derive(Default, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct ClipboardFixtures {
    pub(crate) gled_fixtures: Vec<(Fixture, LayoutPosition)>,
    /// Parent → child, as indices into `gled_fixtures`. A copied run of a
    /// chain is still a chain when it is pasted; only the wiring that led out
    /// of the selection is lost, because there is nothing to lead to.
    #[serde(default)]
    pub(crate) edges: Vec<(usize, usize)>,
}

impl ClipboardFixtures {
    pub(crate) fn is_empty(&self) -> bool {
        self.gled_fixtures.is_empty()
    }
}

/// The selection as clipboard text, or `None` when there is nothing to copy.
pub(crate) fn clipboard_text(patch: &Patch, state: &EditorState) -> Option<String> {
    let clipboard = selected_fixtures(patch, state);
    if clipboard.is_empty() {
        return None;
    }
    serde_json::to_string(&clipboard).ok()
}

/// Fixtures from clipboard text, if it is ours. Anything else is somebody
/// else's copy and is left alone.
pub(crate) fn fixtures_from_clipboard(text: &str) -> Option<ClipboardFixtures> {
    serde_json::from_str::<ClipboardFixtures>(text)
        .ok()
        .filter(|clipboard| !clipboard.is_empty())
}

fn selected_fixtures(patch: &Patch, state: &EditorState) -> ClipboardFixtures {
    if state.selection != LayoutRef::Topology {
        return ClipboardFixtures::default();
    }
    // a stable order, so the edge indices mean the same on both sides
    let ids: Vec<FixtureId> = patch
        .topology
        .fixtures
        .keys()
        .copied()
        .filter(|id| state.selected_fixtures.contains(id))
        .collect();

    let gled_fixtures: Vec<(Fixture, LayoutPosition)> = ids
        .iter()
        .filter_map(|id| {
            let fixture = patch.fixture(*id)?.clone();
            let position = patch.topology.default_layout.members.get(id).copied()?;
            Some((fixture, position))
        })
        .collect();
    if gled_fixtures.len() != ids.len() {
        return ClipboardFixtures::default();
    }

    let edges = ids
        .iter()
        .enumerate()
        .filter_map(|(parent, id)| {
            let child = patch.topology.successor(*id)?;
            let child = ids.iter().position(|other| *other == child)?;
            Some((parent, child))
        })
        .collect();

    ClipboardFixtures {
        gled_fixtures,
        edges,
    }
}

/// Fixtures live in the topology, so that is the only place a copy means
/// anything.
pub(super) fn copy_selected(patch: &Patch, state: &mut EditorState) {
    if state.selection != LayoutRef::Topology {
        return;
    }
    state.clipboard = selected_fixtures(patch, state);
}

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

    /// Cut and paste travel as text, because that is what Cmd+V reads.
    #[test]
    fn fixtures_survive_the_clipboard_as_text() {
        let mut patch = Patch::default();
        let mut state = EditorState::default();
        let id = patch.add_fixture(Fixture::new("bar"), LayoutPosition::at(pos2(10.0, 20.0)));
        state.select_single(id);

        let text = clipboard_text(&patch, &state).expect("something to copy");
        let restored = fixtures_from_clipboard(&text).expect("our own copy");

        assert_eq!(restored.gled_fixtures.len(), 1);
        assert_eq!(restored.gled_fixtures[0].0.name, "bar");
        assert_eq!(restored.gled_fixtures[0].1.pos, pos2(10.0, 20.0));
    }

    /// Anything else on the clipboard belongs to whoever put it there.
    #[test]
    fn foreign_clipboard_text_is_not_ours() {
        assert!(fixtures_from_clipboard("hello").is_none());
        assert!(fixtures_from_clipboard("{\"gled_fixtures\":[]}").is_none());
    }
}