gled-ui 2.32.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! What the patch canvas is asked to do, from its context menu, the keyboard
//! and the clipboard, and how each request changes the patch as one undo
//! step.

use crate::canvas::context_menu::ContextMenu;
use crate::fixture_editor::canvas::ask_background;
use crate::fixture_editor::clipboard::{clipboard_text, copy_selected, fixtures_from_clipboard};
use crate::fixture_editor::commands::EditorTransaction::{
    CopySelected, CutSelected, DuplicateSelected, Paste, Redo, RemoveSelected, SelectAll, Undo,
};
use crate::fixture_editor::state::{CanvasMode, EditorState, PatchEdit};
use egui::{Key, KeyboardShortcut, Modifiers, Response, Vec2, vec2};
use gled_model::asset_id::AssetId;
use gled_model::collection::Collections;
use gled_model::patching::Patch;
use gled_model::patching::definition::FixtureDefinition;
use gled_model::patching::fixture::{Fixture, FixtureId, LayoutPosition};
use gled_model::patching::layout::LayoutRef;

/// What the canvas's context menu and shortcuts ask for.
#[derive(Clone)]
enum CanvasAction {
    Edit(EditorTransaction),
    ChangeBackground,
}

pub(super) fn context_actions(
    edit: &mut PatchEdit<'_>,
    state: &mut EditorState,
    collections: &Collections,
    mode: CanvasMode,
    canvas_response: &Response,
) {
    let ctx = &canvas_response.ctx;
    let command = |key| KeyboardShortcut::new(Modifiers::COMMAND, key);
    let mut menu = ContextMenu::new(ctx).entry(
        "Select All",
        CanvasAction::Edit(SelectAll),
        Some(command(Key::A)),
    );
    if mode == CanvasMode::Topology {
        // egui turns the clipboard shortcuts into events of their own, which
        // `clipboard_action` handles; the menu only shows them.
        menu = menu
            .entry(
                "Duplicate Fixture",
                CanvasAction::Edit(DuplicateSelected),
                Some(command(Key::D)),
            )
            .entry_shown("Copy", CanvasAction::Edit(CopySelected), command(Key::C))
            .entry_shown("Cut", CanvasAction::Edit(CutSelected), command(Key::X))
            .entry_shown("Paste", CanvasAction::Edit(Paste), command(Key::V));
    }
    menu = menu.entry(
        remove_label(mode),
        CanvasAction::Edit(RemoveSelected),
        Some(KeyboardShortcut::new(Modifiers::NONE, Key::Delete)),
    );
    menu = menu.entry("Change Background", CanvasAction::ChangeBackground, None);
    // A shortcut also matches with shift held, so the shifted one comes
    // first. Backspace deletes on a Mac keyboard; Ctrl+Y redoes elsewhere.
    let menu = menu
        .entry(
            "Redo",
            CanvasAction::Edit(Redo),
            Some(KeyboardShortcut::new(
                Modifiers::COMMAND | Modifiers::SHIFT,
                Key::Z,
            )),
        )
        .entry("Undo", CanvasAction::Edit(Undo), Some(command(Key::Z)))
        .key(
            KeyboardShortcut::new(Modifiers::NONE, Key::Backspace),
            CanvasAction::Edit(RemoveSelected),
        )
        .key(command(Key::Y), CanvasAction::Edit(Redo));

    let mut transaction = match menu.show(canvas_response) {
        Some(CanvasAction::Edit(edit)) => Some(edit),
        Some(CanvasAction::ChangeBackground) => {
            ask_background(ctx, edit.value, state);
            None
        }
        None => None,
    };

    if let Some(clipboard) = clipboard_action(state, ctx, mode) {
        transaction = Some(clipboard);
    }

    if let Some(transaction) = transaction {
        copy_to_system_clipboard(ctx, edit.value, state, &transaction);
        process_transaction(edit, state, collections, transaction);
    }
}

/// A copy or a cut, from the menu or the keyboard, puts the selection on the
/// system clipboard too: that is what Cmd+V pastes from, so a copy kept only
/// in the editor would have it paste whatever was copied before.
fn copy_to_system_clipboard(
    ctx: &egui::Context,
    patch: &Patch,
    state: &EditorState,
    transaction: &EditorTransaction,
) {
    if matches!(transaction, CopySelected | CutSelected)
        && let Some(text) = clipboard_text(patch, state)
    {
        ctx.copy_text(text);
    }
}

/// Cut, copy and paste.
///
/// egui turns the clipboard shortcuts into their own events before they can
/// reach a keyboard shortcut, and only reports a paste when the system
/// clipboard holds something — so a copy writes the fixtures there as text
/// ([`copy_to_system_clipboard`]), and a paste reads them back.
fn clipboard_action(
    state: &mut EditorState,
    ctx: &egui::Context,
    mode: CanvasMode,
) -> Option<EditorTransaction> {
    // A text field has first claim on the clipboard. Any other widget with
    // the focus does not: a button keeps it after a click.
    if mode != CanvasMode::Topology || ctx.text_edit_focused() {
        return None;
    }

    let mut action = None;
    ctx.input_mut(|input| {
        input.events.retain(|event| match event {
            egui::Event::Copy => {
                action = Some(CopySelected);
                false
            }
            egui::Event::Cut => {
                action = Some(CutSelected);
                false
            }
            egui::Event::Paste(text) => match fixtures_from_clipboard(text) {
                Some(clipboard) => {
                    state.clipboard = clipboard;
                    action = Some(Paste);
                    false
                }
                // somebody else's copy: leave it for whoever wants it
                None => true,
            },
            _ => true,
        });
    });
    action
}

#[derive(Clone)]
pub(crate) enum EditorTransaction {
    /// Only meaningful in the topology — fixtures exist there and nowhere else.
    /// The definition is resolved by the caller, which has the asset
    /// collections; `None` leaves the fixture unpatched.
    AddFixture(LayoutPosition, Option<AssetId<FixtureDefinition>>),
    MakeLeaf(FixtureId),
    MakeSuccessor(FixtureId, FixtureId),
    /// Deletes fixtures in the topology; drops them from the layout elsewhere.
    RemoveSelected,
    DuplicateSelected,
    /// Everything the canvas is showing and may edit.
    SelectAll,
    /// Only the topology owns fixtures, so only it can copy or cut them.
    CopySelected,
    CutSelected,
    Paste,
    /// Back to the patch before the last change, and forward again.
    Undo,
    Redo,
}

/// How far a duplicate or a pasted copy lands from its original, in scene
/// units, so it is not hidden under it.
const PASTE_OFFSET: Vec2 = vec2(20.0, 20.0);

/// Takes `ids` out of the rig. Whatever followed each of them is a chain of
/// its own now, with a universe of its own.
fn remove_fixtures(
    patch: &mut Patch,
    ids: impl IntoIterator<Item = FixtureId>,
    collections: &Collections,
) {
    for id in ids {
        let detached = patch.topology.successor(id);
        patch.remove_fixture(id);
        if let Some(detached) = detached {
            rehome(patch, detached, collections);
        }
    }
}

/// Adds a copy of `fixture`, unwired, beside `position`.
fn copy_of(patch: &mut Patch, fixture: &Fixture, mut position: LayoutPosition) -> FixtureId {
    position.pos += PASTE_OFFSET;
    let mut copy = Fixture::new(fixture.name.clone());
    copy.definition = fixture.definition;
    patch.add_fixture(copy, position)
}

/// Gives a chain that has just become one a universe of its own.
///
/// The topology hands out the lowest universe no port names, which is not the
/// same as the lowest free one: a chain wider than a universe spills into the
/// next, and the new chain would start inside it.
fn rehome(patch: &mut Patch, head: FixtureId, collections: &Collections) {
    if !patch.topology.is_chain_head(head) {
        return;
    }
    let universe = patch.lowest_free_universe(collections, Some(head));
    if let Some(port) = patch.topology.ports.get_mut(&head) {
        port.universe = universe;
    }
}

/// Runs `transaction` on the patch being edited, as one undo step.
pub(crate) fn process_transaction(
    edit: &mut PatchEdit<'_>,
    state: &mut EditorState,
    collections: &Collections,
    transaction: EditorTransaction,
) {
    let done = match transaction {
        EditorTransaction::Undo => edit.undo(),
        EditorTransaction::Redo => edit.redo(),
        transaction => {
            edit.apply(|patch| apply_transaction(patch, state, collections, transaction));
            return;
        }
    };
    // what was in flight or selected may be gone
    if done {
        state.drag = None;
        state.clamp(edit.value);
    }
}

/// Runs `transaction` on `patch`. Several of them make one undo step when
/// they run in one [`crate::canvas::edit::Edit::apply`].
pub(crate) fn apply_transaction(
    patch: &mut Patch,
    state: &mut EditorState,
    collections: &Collections,
    transaction: EditorTransaction,
) {
    match transaction {
        EditorTransaction::AddFixture(position, definition) => {
            let mut fixture = Fixture::new("Light");
            fixture.definition = definition;
            let id = patch.add_fixture(fixture, position);
            rehome(patch, id, collections);
            state.select_single(id);
        }
        EditorTransaction::MakeLeaf(id) => {
            // whatever followed is a chain of its own now
            let detached = patch.topology.successor(id);
            patch.topology.make_leaf(id);
            if let Some(detached) = detached {
                rehome(patch, detached, collections);
            }
        }
        EditorTransaction::MakeSuccessor(parent, child) => {
            let detached = patch.topology.successor(parent);
            if patch.topology.make_successor(parent, child)
                && let Some(detached) = detached
            {
                rehome(patch, detached, collections);
            }
        }
        EditorTransaction::RemoveSelected => {
            let selected = std::mem::take(&mut state.selected_fixtures);
            match state.selection {
                LayoutRef::Topology => remove_fixtures(patch, selected, collections),
                layout => {
                    if let Some(layout) = patch.layout_mut(layout) {
                        for id in selected {
                            layout.forget(id);
                        }
                    }
                }
            }
        }
        EditorTransaction::DuplicateSelected => {
            // A layout is a subset, not an owner, so duplication only creates
            // fixtures in the topology.
            if state.selection != LayoutRef::Topology {
                return;
            }
            let selected: Vec<FixtureId> = state.selected_fixtures.iter().copied().collect();
            let mut created = vec![];
            for id in selected {
                let Some(source) = patch.fixture(id).cloned() else {
                    continue;
                };
                let Some(position) = patch.topology.default_layout.members.get(&id).copied() else {
                    continue;
                };
                created.push(copy_of(patch, &source, position));
            }
            for id in &created {
                rehome(patch, *id, collections);
            }
            if !created.is_empty() {
                state.selected_fixtures = created.into_iter().collect();
            }
        }
        EditorTransaction::SelectAll => {
            // what the canvas is showing: a layout arranges its members only
            state.selected_fixtures = patch
                .layout(state.selection)
                .map(|layout| layout.members.keys().copied().collect())
                .unwrap_or_default();
            state.selected_port = None;
        }
        EditorTransaction::CopySelected => copy_selected(patch, state),
        EditorTransaction::CutSelected => {
            copy_selected(patch, state);
            let selected = std::mem::take(&mut state.selected_fixtures);
            remove_fixtures(patch, selected, collections);
        }
        EditorTransaction::Paste => {
            if state.selection != LayoutRef::Topology {
                return;
            }
            let clipboard = state.clipboard.clone();
            let created: Vec<FixtureId> = clipboard
                .gled_fixtures
                .iter()
                .map(|(fixture, position)| copy_of(patch, fixture, *position))
                .collect();
            // the copies are wired to each other exactly as the originals are
            for (parent, child) in clipboard.edges {
                if let (Some(parent), Some(child)) = (created.get(parent), created.get(child)) {
                    patch.topology.make_successor(*parent, *child);
                }
            }
            // once wired, only the heads still need a universe
            for id in &created {
                rehome(patch, *id, collections);
            }
            if !created.is_empty() {
                state.selected_fixtures = created.into_iter().collect();
                state.selected_port = None;
            }
        }
        // the history belongs to the edit, see `process_transaction`
        EditorTransaction::Undo | EditorTransaction::Redo => {}
    }
}

/// What removing the selection is called in the context menu: the topology
/// deletes fixtures, a layout only lets go of them.
pub(crate) fn remove_label(mode: CanvasMode) -> &'static str {
    match mode {
        CanvasMode::Topology => "Remove Fixture",
        _ => "Remove from Layout",
    }
}

#[cfg(test)]
mod tests;