gled-ui 2.31.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Changing the definition: every edit goes through [`Edit`], which keeps an
//! undo snapshot, and the canvas's actions are [`EditorAction`]s, offered in
//! its context menu and on the keyboard.

use crate::canvas::context_menu::ContextMenu;
use crate::fixture_definition_editor::state::EditorState;
use egui::{Key, KeyboardShortcut, Modifiers, Response};
use gled_model::patching::definition::{DefinitionError, FixtureDefinition};

/// How far a duplicate lands from its original, in metres.
const DUPLICATE_OFFSET_M: f32 = 0.01;

/// The definition being edited this frame, with its undo history.
pub(crate) type Edit<'a> = crate::canvas::edit::Edit<'a, FixtureDefinition>;

/// What the definition editor's changes do beyond an undo step: they clear
/// the last message, end a drag in flight, and keep the editor's state valid
/// for the definition they leave behind.
pub(crate) trait DefinitionEdit {
    /// Applies `change` as one undo step.
    fn change<R>(
        &mut self,
        state: &mut EditorState,
        change: impl FnOnce(&mut FixtureDefinition) -> R,
    ) -> R;

    /// Like [`DefinitionEdit::change`] for edits that can be refused; a
    /// refusal is shown to the user. Returns whether the edit was made.
    fn try_change(
        &mut self,
        state: &mut EditorState,
        change: impl FnOnce(&mut FixtureDefinition) -> Result<(), DefinitionError>,
    ) -> bool;
}

impl DefinitionEdit for Edit<'_> {
    fn change<R>(
        &mut self,
        state: &mut EditorState,
        change: impl FnOnce(&mut FixtureDefinition) -> R,
    ) -> R {
        state.message = None;
        // A drag holds on to pixels by their index, which an edit made with
        // the button still down — a key pressed mid-drag — may give to
        // other pixels; carrying on would move those.
        state.drag = None;
        let result = self.apply(change);
        state.clamp(self.value);
        result
    }

    fn try_change(
        &mut self,
        state: &mut EditorState,
        change: impl FnOnce(&mut FixtureDefinition) -> Result<(), DefinitionError>,
    ) -> bool {
        match self.change(state, change) {
            Ok(()) => true,
            Err(error) => {
                state.message = Some(error.to_string());
                false
            }
        }
    }
}

/// What the canvas's context menu and keyboard shortcuts do.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum EditorAction {
    SelectAll,
    Delete,
    Duplicate,
    ReverseOrder,
    MoveToFront,
    Group,
    Ungroup,
    EachOwnGroup,
    Undo,
    Redo,
}

impl EditorAction {
    pub(crate) fn apply(self, edit: &mut Edit<'_>, state: &mut EditorState) {
        state.message = None;
        let selection = state.selection.clone();
        let mode = state.mode;
        match self {
            Self::SelectAll => state.selection = (0..edit.value.pixels.len()).collect(),
            Self::Delete => {
                edit.change(state, |definition| definition.remove_pixels(&selection));
                state.selection.clear();
            }
            Self::Duplicate => {
                let offset = [DUPLICATE_OFFSET_M, DUPLICATE_OFFSET_M, 0.0];
                let copies = edit.change(state, |definition| {
                    definition.duplicate_pixels(&selection, offset)
                });
                state.selection = copies.collect();
            }
            Self::ReverseOrder => {
                edit.change(state, |definition| definition.reverse_pixels(&selection));
            }
            Self::MoveToFront => {
                edit.change(state, |definition| definition.move_to_front(&selection));
                state.selection = (0..selection.len()).collect();
            }
            Self::Group => {
                edit.try_change(state, |definition| {
                    definition.group_selection(mode, &selection)
                });
            }
            Self::Ungroup => {
                edit.try_change(state, |definition| {
                    definition.ungroup_selection(mode, &selection)
                });
            }
            Self::EachOwnGroup => {
                edit.try_change(state, |definition| definition.each_own_group(mode));
            }
            Self::Undo | Self::Redo => {
                let done = if self == Self::Undo {
                    edit.undo()
                } else {
                    edit.redo()
                };
                if done {
                    state.drag = None;
                    state.clamp(edit.value);
                }
            }
        }
    }
}

/// The canvas's context menu and its shortcuts. Returns what was asked for.
pub(crate) fn context_actions(response: &Response) -> Option<EditorAction> {
    let command = |key| Some(KeyboardShortcut::new(Modifiers::COMMAND, key));
    let command_shift = |key| {
        Some(KeyboardShortcut::new(
            Modifiers::COMMAND | Modifiers::SHIFT,
            key,
        ))
    };
    // A shortcut also matches with shift held, so the shifted ones come
    // first. Backspace deletes on a Mac keyboard; Ctrl+Y redoes elsewhere.
    ContextMenu::new(&response.ctx)
        .entry("Select All", EditorAction::SelectAll, command(Key::A))
        .entry("Duplicate", EditorAction::Duplicate, command(Key::D))
        .entry(
            "Delete",
            EditorAction::Delete,
            Some(KeyboardShortcut::new(Modifiers::NONE, Key::Delete)),
        )
        .entry("Reverse DMX Order", EditorAction::ReverseOrder, None)
        .entry("Make First in DMX Order", EditorAction::MoveToFront, None)
        .entry(
            "Ungroup in Mode",
            EditorAction::Ungroup,
            command_shift(Key::G),
        )
        .entry("Group in Mode", EditorAction::Group, command(Key::G))
        .entry("Each Own Group in Mode", EditorAction::EachOwnGroup, None)
        .entry("Redo", EditorAction::Redo, command_shift(Key::Z))
        .entry("Undo", EditorAction::Undo, command(Key::Z))
        .key(
            KeyboardShortcut::new(Modifiers::NONE, Key::Backspace),
            EditorAction::Delete,
        )
        .key(
            KeyboardShortcut::new(Modifiers::COMMAND, Key::Y),
            EditorAction::Redo,
        )
        .show(response)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::canvas::undo::UndoStack;
    use crate::fixture_definition_editor::state::Drag;
    use egui::Pos2;
    use gled_model::patching::definition::{EmittedColor, Mode, Pixel};

    /// Regression: deleting pixels with the keyboard while dragging others
    /// left the drag holding indices that now named other pixels, and the
    /// next frame moved those.
    #[test]
    fn an_edit_ends_the_drag_in_flight() {
        let pixel = |x_m: f32| Pixel {
            name: "p".to_string(),
            model: 0,
            position_m: [x_m, 0.0, 0.0],
            rotation: 0.0,
            channels: vec![EmittedColor::Red, EmittedColor::Green, EmittedColor::Blue],
        };
        let mut definition = FixtureDefinition {
            pixels: (0..4).map(|index| pixel(index as f32 * 0.1)).collect(),
            modes: vec![Mode::individual(4)],
            ..Default::default()
        };
        let mut undo = UndoStack::default();
        let mut edit = Edit::new(&mut definition, &mut undo);
        let mut state = EditorState {
            selection: [1].into_iter().collect(),
            drag: Some(Drag::Move {
                pointer_start: Pos2::ZERO,
                start: vec![(1, [0.1, 0.0, 0.0])],
            }),
            ..Default::default()
        };

        EditorAction::Delete.apply(&mut edit, &mut state);

        assert!(state.drag.is_none());
        assert_eq!(edit.value.pixels.len(), 3);
    }
}