gled-ui 2.34.0

gled's egui interface, shared by the desktop app and the browser
Documentation
use super::*;
use crate::canvas::undo::UndoStack;
use crate::fixture_editor::clipboard::fixtures_from_clipboard;
use egui::pos2;

/// Runs `transaction` as the editor does, with a history of its own and
/// no definitions.
fn transact(patch: &mut Patch, state: &mut EditorState, transaction: EditorTransaction) {
    transact_with(patch, state, &Collections::default(), transaction);
}

/// [`transact`] with the definitions in `collections`.
fn transact_with(
    patch: &mut Patch,
    state: &mut EditorState,
    collections: &Collections,
    transaction: EditorTransaction,
) {
    let mut undo = UndoStack::default();
    process_transaction(
        &mut PatchEdit::new(patch, &mut undo),
        state,
        collections,
        transaction,
    );
}

/// Every transaction is one undo step, and undoing it drops a selection
/// of what it created.
#[test]
fn a_transaction_is_undone_in_one_step() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let mut undo = UndoStack::default();
    let collections = Collections::default();
    let mut edit = PatchEdit::new(&mut patch, &mut undo);
    let add = || EditorTransaction::AddFixture(LayoutPosition::default(), None);

    process_transaction(&mut edit, &mut state, &collections, add());
    process_transaction(&mut edit, &mut state, &collections, add());
    assert_eq!(edit.value.topology.fixtures.len(), 2);

    process_transaction(&mut edit, &mut state, &collections, EditorTransaction::Undo);
    assert_eq!(edit.value.topology.fixtures.len(), 1);
    assert!(
        state.selected_fixtures.is_empty(),
        "the fixture the undo took away is no longer selected"
    );

    process_transaction(&mut edit, &mut state, &collections, EditorTransaction::Redo);
    assert_eq!(edit.value.topology.fixtures.len(), 2);
}

/// Copying changes nothing in the patch, so there is nothing to undo.
#[test]
fn a_transaction_that_changes_nothing_leaves_no_step() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let mut undo = UndoStack::default();
    let mut edit = PatchEdit::new(&mut patch, &mut undo);

    process_transaction(
        &mut edit,
        &mut state,
        &Collections::default(),
        EditorTransaction::CopySelected,
    );

    assert!(!edit.can_undo());
}

/// Asset collections holding one 100-pixel definition: three of those no
/// longer fit a universe.
fn wide_collections() -> (
    Collections,
    gled_model::asset_id::AssetId<gled_model::patching::definition::FixtureDefinition>,
) {
    use gled_model::patching::definition::{EmittedColor, FixtureDefinition, Mode, Pixel};
    let definition = FixtureDefinition {
        size_m: [1.0, 0.05, 0.05],
        pixels: (0..100)
            .map(|i| Pixel {
                name: format!("pixel-{i}"),
                model: 0,
                position_m: [i as f32 * 0.01, 0.0, 0.0],
                rotation: 0.0,
                channels: vec![EmittedColor::Red, EmittedColor::Green, EmittedColor::Blue],
            })
            .collect(),
        modes: vec![Mode::individual(100)],
        ..Default::default()
    };
    let asset = gled_model::asset::Asset {
        id: gled_model::asset_id::AssetId::default(),
        path: vec!["wide".to_string()],
        data: definition,
    };
    let id = asset.id;
    let mut collection = gled_model::collection::Collection::default();
    collection.set_asset(asset);
    let mut collections = Collections::default();
    collections.insert::<FixtureDefinition>(collection);
    (collections, id)
}

/// Regression: adding a fixture put it on the lowest universe no port
/// named, so it landed inside a universe an existing chain had already
/// run into.
#[test]
fn a_added_fixture_starts_past_a_chain_that_overflowed() {
    let (collections, definition) = wide_collections();
    let mut patch = Patch::default();
    let mut state = EditorState::default();

    let mut head = Fixture::new("head");
    head.definition = Some(definition);
    let head = patch.add_fixture(head, LayoutPosition::default());
    let mut tail = Fixture::new("tail");
    tail.definition = Some(definition);
    let tail = patch.add_fixture(tail, LayoutPosition::default());
    patch.topology.make_successor(head, tail);
    // the chain now runs into universe 1
    assert_eq!(patch.addresses(&collections)[&tail].into_pair(), (1, 0));

    transact_with(
        &mut patch,
        &mut state,
        &collections,
        EditorTransaction::AddFixture(LayoutPosition::default(), Some(definition)),
    );

    let added = *state
        .selected_fixtures
        .iter()
        .next()
        .expect("the added fixture is selected");
    assert_eq!(patch.topology.ports[&added].universe, 2);
}

/// Detaching a chain gives it a free universe too, not one the chain it
/// came from has spilled into.
#[test]
fn a_detached_chain_starts_past_the_one_it_left() {
    let (collections, definition) = wide_collections();
    let mut patch = Patch::default();
    let mut state = EditorState::default();

    let ids: Vec<FixtureId> = ["a", "b", "c"]
        .iter()
        .map(|name| {
            let mut fixture = Fixture::new(*name);
            fixture.definition = Some(definition);
            patch.add_fixture(fixture, LayoutPosition::default())
        })
        .collect();
    patch.topology.make_successor(ids[0], ids[1]);
    patch.topology.make_successor(ids[1], ids[2]);

    // 300 channels each, so the chain already covers universes 0 and 1
    transact_with(
        &mut patch,
        &mut state,
        &collections,
        EditorTransaction::MakeLeaf(ids[1]),
    );

    assert_eq!(patch.addresses(&collections)[&ids[1]].into_pair(), (1, 0));
    assert_eq!(
        patch.topology.ports[&ids[2]].universe, 2,
        "the detached chain clears the one it left"
    );
}

/// Regression: Copy and Cut from the context menu kept the fixtures in the
/// editor only, so Cmd+V then pasted whatever had been copied before.
#[test]
fn a_copy_from_the_menu_reaches_the_system_clipboard() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let id = patch.add_fixture(Fixture::new("bar"), LayoutPosition::default());
    state.select_single(id);

    for transaction in [
        EditorTransaction::CopySelected,
        EditorTransaction::CutSelected,
    ] {
        let ctx = egui::Context::default();
        let mut output = ctx.run_ui(Default::default(), |ui| {
            copy_to_system_clipboard(ui.ctx(), &patch, &state, &transaction);
        });
        output.textures_delta.clear();

        let copied = output
            .platform_output
            .commands
            .iter()
            .find_map(|command| match command {
                egui::OutputCommand::CopyText(text) => fixtures_from_clipboard(text),
                _ => None,
            })
            .expect("the fixtures are on the system clipboard");
        assert_eq!(copied.gled_fixtures[0].0.name, "bar");
    }
}

/// Pasting drops the copies beside their originals rather than on top.
#[test]
fn pasting_offsets_the_copies_and_selects_them() {
    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);
    transact(&mut patch, &mut state, EditorTransaction::CopySelected);

    transact(&mut patch, &mut state, EditorTransaction::Paste);

    assert_eq!(patch.topology.fixtures.len(), 2);
    assert_eq!(state.selected_fixtures.len(), 1);
    let pasted = *state
        .selected_fixtures
        .iter()
        .next()
        .expect("the pasted fixture is selected");
    assert_ne!(pasted, id);
    assert_eq!(
        patch.topology.default_layout.members[&pasted].pos,
        pos2(30.0, 40.0)
    );
}

/// A copied run of a chain is still a chain once it is pasted: the wiring
/// inside the selection travels with it.
#[test]
fn pasting_keeps_the_wiring_inside_the_selection() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let head = patch.add_fixture(Fixture::new("head"), LayoutPosition::default());
    let tail = patch.add_fixture(Fixture::new("tail"), LayoutPosition::default());
    let outside = patch.add_fixture(Fixture::new("outside"), LayoutPosition::default());
    patch.topology.make_successor(head, tail);
    patch.topology.make_successor(tail, outside);
    state.selected_fixtures = [head, tail].into_iter().collect();

    transact(&mut patch, &mut state, EditorTransaction::CopySelected);
    transact(&mut patch, &mut state, EditorTransaction::Paste);

    let pasted: Vec<FixtureId> = state.selected_fixtures.iter().copied().collect();
    assert_eq!(pasted.len(), 2);
    let joined = pasted
        .iter()
        .filter_map(|id| patch.topology.successor(*id))
        .filter(|successor| pasted.contains(successor))
        .count();
    assert_eq!(joined, 1, "the two copies are wired to each other");

    // and the chain the copies came from is untouched
    assert_eq!(patch.topology.successor(head), Some(tail));
    assert_eq!(patch.topology.successor(tail), Some(outside));
}

/// Wiring that led out of the selection has nothing to lead to.
#[test]
fn pasting_does_not_reach_outside_the_selection() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let head = patch.add_fixture(Fixture::new("head"), LayoutPosition::default());
    let outside = patch.add_fixture(Fixture::new("outside"), LayoutPosition::default());
    patch.topology.make_successor(head, outside);
    state.select_single(head);

    transact(&mut patch, &mut state, EditorTransaction::CopySelected);
    transact(&mut patch, &mut state, EditorTransaction::Paste);

    let pasted = *state
        .selected_fixtures
        .iter()
        .next()
        .expect("the pasted fixture is selected");
    assert_eq!(patch.topology.successor(pasted), None);
    assert_eq!(patch.topology.successor(head), Some(outside));
}

/// Cutting takes the fixtures out of the rig, but keeps them to paste.
#[test]
fn cutting_removes_the_fixtures_and_keeps_them() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let id = patch.add_fixture(Fixture::new("bar"), LayoutPosition::default());
    state.select_single(id);

    transact(&mut patch, &mut state, EditorTransaction::CutSelected);

    assert!(patch.topology.fixtures.is_empty());
    assert_eq!(state.clipboard.gled_fixtures.len(), 1);

    transact(&mut patch, &mut state, EditorTransaction::Paste);
    assert_eq!(patch.topology.fixtures.len(), 1);
}

/// Select all takes what the canvas is showing: a layout arranges only its
/// own members.
#[test]
fn select_all_takes_the_drawn_layouts_members() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let inside = patch.add_fixture(Fixture::new("a"), LayoutPosition::default());
    patch.add_fixture(Fixture::new("b"), LayoutPosition::default());
    let layout = LayoutRef::Layout(patch.add_layout());
    patch.add_member(layout, inside);

    transact(&mut patch, &mut state, EditorTransaction::SelectAll);
    assert_eq!(state.selected_fixtures.len(), 2, "the topology holds both");

    state.select_layout(layout, 0);
    transact(&mut patch, &mut state, EditorTransaction::SelectAll);
    assert_eq!(state.selected_fixtures, [inside].into_iter().collect());
}

#[test]
fn adding_a_fixture_selects_it() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let add = EditorTransaction::AddFixture(LayoutPosition::default(), None);
    transact(&mut patch, &mut state, add);
    assert_eq!(patch.topology.fixtures.len(), 1);
    assert_eq!(state.selected_fixtures.len(), 1);
}

#[test]
fn removing_in_the_topology_deletes_the_fixture() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let id = patch.add_fixture(Fixture::new("a"), LayoutPosition::default());
    state.select_single(id);

    transact(&mut patch, &mut state, EditorTransaction::RemoveSelected);

    assert!(patch.topology.fixtures.is_empty());
}

#[test]
fn removing_in_a_layout_only_drops_membership() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let id = patch.add_fixture(Fixture::new("a"), LayoutPosition::default());
    let layout = LayoutRef::Layout(patch.add_layout());
    patch.add_member(layout, id);
    state.select_layout(layout, 0);
    state.select_single(id);

    transact(&mut patch, &mut state, EditorTransaction::RemoveSelected);

    assert!(
        !patch
            .layout(layout)
            .expect("the layout was just added")
            .contains(id)
    );
    assert!(
        patch.topology.fixtures.contains_key(&id),
        "the fixture itself survives"
    );
}

#[test]
fn duplicating_is_topology_only() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let id = patch.add_fixture(Fixture::new("a"), LayoutPosition::default());
    let layout = LayoutRef::Layout(patch.add_layout());
    patch.add_member(layout, id);
    state.select_layout(layout, 0);
    state.select_single(id);

    transact(&mut patch, &mut state, EditorTransaction::DuplicateSelected);

    assert_eq!(patch.topology.fixtures.len(), 1);
}

#[test]
fn duplicating_in_the_topology_offsets_the_copy() {
    let mut patch = Patch::default();
    let mut state = EditorState::default();
    let id = patch.add_fixture(Fixture::new("a"), LayoutPosition::default());
    state.select_single(id);

    transact(&mut patch, &mut state, EditorTransaction::DuplicateSelected);

    assert_eq!(patch.topology.fixtures.len(), 2);
    let copy = *state
        .selected_fixtures
        .iter()
        .next()
        .expect("the copy is selected");
    assert_ne!(copy, id);
    let original = patch.topology.default_layout.members[&id];
    let copied = patch.topology.default_layout.members[&copy];
    assert_eq!(copied.pos, original.pos + PASTE_OFFSET);
}