gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
use crate::canvas::caches::Caches;
use crate::canvas::images::Decoded;
use crate::canvas::selection::ClickIntent;
use crate::canvas::view::SceneView;
use crate::fixture_editor::drag::Drag;
use crate::ui::file_pick::FilePick;
use gled_model::patching::Patch;
use gled_model::patching::fixture::FixtureId;
use gled_model::patching::layout::{LayoutRef, scene_bounds};
use std::collections::{BTreeSet, HashSet};
use std::path::PathBuf;

/// What the canvas is doing. The topology is the only place where fixtures are
/// created, wired and addressed; a layout only arranges them.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CanvasMode {
    /// Spatial editing plus ports, chain edges and connection drag-and-drop.
    Topology,
    /// Spatial editing only.
    Layout,
}

impl CanvasMode {
    pub(crate) fn from_selection(layout: LayoutRef) -> Self {
        match layout {
            LayoutRef::Topology => Self::Topology,
            LayoutRef::Layout(_) => Self::Layout,
        }
    }

    /// Ports and connection drag-and-drop. Wiring is a property of the
    /// topology, so a layout shows it but cannot change it.
    pub(crate) fn edits_network(self) -> bool {
        self == Self::Topology
    }
}

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

/// A background image chosen for a layout, decoded on the chooser's thread.
pub(crate) struct PickedBackground {
    /// The layout it was chosen for, where it was then.
    pub(crate) layout: LayoutRef,
    /// The layout's name then. A layout can be added, removed or undone
    /// while the chooser is open, which moves the others to other indices;
    /// its name, which adding and copying layouts keep unique, still finds
    /// it.
    pub(crate) name: String,
    /// Where the picture goes in the asset repository.
    pub(crate) path: PathBuf,
    pub(crate) bytes: Vec<u8>,
    pub(crate) image: Decoded,
}

impl PickedBackground {
    /// The layout the image is for in `patch`, or `None` when it is gone or
    /// shares its name with another one by now.
    pub(crate) fn target(&self, patch: &Patch) -> Option<LayoutRef> {
        if self.layout == LayoutRef::Topology {
            return Some(LayoutRef::Topology);
        }
        let mut named = patch
            .layouts
            .iter()
            .enumerate()
            .filter(|(_, layout)| layout.name == self.name)
            .map(|(index, _)| LayoutRef::Layout(index));
        let found = named.next()?;
        named.next().is_none().then_some(found)
    }
}

/// Everything the editor needs while running and nothing that outlives it.
/// All persistent data lives in [`gled_model::patching::Patch`].
pub(crate) struct EditorState {
    pub(crate) selection: LayoutRef,
    pub(crate) selected_mode: usize,
    pub(crate) selected_fixtures: BTreeSet<FixtureId>,
    /// The chain head whose network output is selected. Mutually exclusive with
    /// a fixture selection: the inspector shows one or the other.
    pub(crate) selected_port: Option<FixtureId>,
    pub(crate) pointer_down_on: HashSet<FixtureId>,
    pub(crate) view: SceneView,
    /// The rubber band in the membership picker, which has no canvas state of
    /// its own beyond it.
    pub(crate) member_band: crate::fixture_editor::member_preview::Band,
    /// What the pointer is doing on the canvas.
    pub(crate) drag: Option<Drag>,
    /// The file chooser for a layout's background image.
    pub(crate) background_pick: FilePick<PickedBackground>,
    pub(crate) picking_members: bool,
    /// Fixtures cut or copied in the topology, waiting to be pasted, with the
    /// wiring between them.
    pub(crate) clipboard: crate::fixture_editor::clipboard::ClipboardFixtures,
    pub(crate) caches: Caches,
}

impl Default for EditorState {
    fn default() -> Self {
        Self {
            selection: LayoutRef::Topology,
            selected_mode: 0,
            selected_fixtures: BTreeSet::new(),
            selected_port: None,
            pointer_down_on: HashSet::new(),
            view: SceneView::centred_on(scene_bounds().center()),
            member_band: None,
            drag: None,
            background_pick: FilePick::default(),
            picking_members: false,
            clipboard: Default::default(),
            caches: Caches::default(),
        }
    }
}

impl EditorState {
    /// Plain click — replace the whole selection.
    pub(crate) fn select_single(&mut self, id: FixtureId) {
        self.selected_fixtures.clear();
        self.selected_fixtures.insert(id);
        self.selected_port = None;
    }

    /// A click on the fixture `hit`, or on the background. Whatever changes
    /// the fixture selection leaves the network output.
    pub(crate) fn click(&mut self, intent: ClickIntent, hit: Option<FixtureId>) {
        if hit.is_some() || intent == ClickIntent::Replace {
            self.selected_port = None;
        }
        intent.apply(&mut self.selected_fixtures, hit);
    }

    /// Selects `selection`, as a rubber band does. Like a click, a band that
    /// changes the fixture selection leaves the network output.
    pub(crate) fn select_fixtures(&mut self, selection: BTreeSet<FixtureId>) {
        if selection != self.selected_fixtures {
            self.selected_port = None;
        }
        self.selected_fixtures = selection;
    }

    /// Clicking a chain's network output inspects it instead of a fixture.
    pub(crate) fn select_port(&mut self, id: FixtureId) {
        self.selected_fixtures.clear();
        self.selected_port = Some(id);
    }

    pub(crate) fn clear_selection(&mut self) {
        self.selected_fixtures.clear();
        self.selected_port = None;
    }

    /// Opens or closes the fixture picker. The canvas behind it keeps its
    /// selection, but anything in flight there is dropped: a drag cannot
    /// carry on under a window that has taken the pointer.
    pub(crate) fn pick_members(&mut self, picking: bool) {
        self.picking_members = picking;
        self.member_band = None;
        self.drag = None;
        self.pointer_down_on.clear();
    }

    /// Point the editor at a layout, dropping selection state that belonged to
    /// the previous one.
    pub(crate) fn select_layout(&mut self, layout: LayoutRef, mode: usize) {
        if self.selection != layout {
            self.clear_selection();
            self.drag = None;
            self.pointer_down_on.clear();
        }
        self.selection = layout;
        self.selected_mode = mode;
        self.picking_members = false;
    }

    /// Keeps the selection pointing at something that still exists — layouts,
    /// modes and fixtures can be deleted while selected, and an undo can take
    /// them away.
    pub(crate) fn clamp(&mut self, patch: &Patch) {
        if patch.layout(self.selection).is_none() {
            self.select_layout(LayoutRef::Topology, 0);
        }
        if let Some(layout) = patch.layout(self.selection) {
            self.selected_fixtures
                .retain(|id| layout.members.contains_key(id));
        }
        // A port follows its chain: rewiring moves the network output onto the
        // new head rather than dropping what the user was looking at.
        if let Some(id) = self.selected_port {
            self.selected_port = patch
                .topology
                .fixtures
                .contains_key(&id)
                .then(|| patch.topology.chain_head_of(id));
        }
        let modes = patch
            .layout(self.selection)
            .map(|layout| layout.modes.len())
            .unwrap_or(1);
        if self.selected_mode >= modes {
            self.selected_mode = 0;
        }
    }

    pub(crate) fn canvas_mode(&self) -> CanvasMode {
        CanvasMode::from_selection(self.selection)
    }
}

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

    /// A modified click beside every fixture keeps the selection, as it
    /// does in the definition editor; a plain one clears it.
    #[test]
    fn only_a_plain_click_on_the_background_deselects() {
        let mut state = EditorState::default();
        let id = FixtureId::new();
        state.click(ClickIntent::Toggle, Some(id));
        assert!(state.selected_fixtures.contains(&id));

        state.click(ClickIntent::Add, None);
        assert!(state.selected_fixtures.contains(&id));
        state.click(ClickIntent::Replace, None);
        assert!(state.selected_fixtures.is_empty());
    }

    #[test]
    fn selecting_another_layout_drops_the_selection() {
        let mut state = EditorState::default();
        state.selected_fixtures.insert(FixtureId::new());
        state.select_layout(LayoutRef::Layout(0), 0);
        assert!(state.selected_fixtures.is_empty());
    }

    #[test]
    fn reselecting_the_same_layout_keeps_the_selection() {
        let mut state = EditorState::default();
        let id = FixtureId::new();
        state.selected_fixtures.insert(id);
        state.select_layout(LayoutRef::Topology, 0);
        assert!(state.selected_fixtures.contains(&id));
    }

    /// An undo can take away a fixture that is selected, or the layout it
    /// was selected in.
    #[test]
    fn clamping_drops_what_the_patch_no_longer_has() {
        let mut patch = Patch::default();
        let kept = patch.add_fixture(
            gled_model::patching::fixture::Fixture::new("kept"),
            Default::default(),
        );
        let mut state = EditorState::default();
        state.selected_fixtures.extend([kept, FixtureId::new()]);
        state.clamp(&patch);
        assert_eq!(state.selected_fixtures, [kept].into_iter().collect());

        state.select_layout(LayoutRef::Layout(3), 0);
        state.clamp(&patch);
        assert_eq!(state.selection, LayoutRef::Topology);
    }

    fn picked(layout: LayoutRef, name: &str) -> PickedBackground {
        PickedBackground {
            layout,
            name: name.to_string(),
            path: PathBuf::from("pictures/stage.png"),
            bytes: Vec::new(),
            image: Err("not decoded in a test".to_string()),
        }
    }

    /// Regression: an image was matched to its layout by index, so removing
    /// a layout while the chooser was open put it on the next one.
    #[test]
    fn a_background_finds_its_layout_after_another_is_removed() {
        let mut patch = Patch::default();
        let first = patch.add_layout();
        let second = patch.add_layout();
        let name = patch.layouts[second].name.clone();
        let chosen = picked(LayoutRef::Layout(second), &name);

        patch.remove_layout(first);

        assert_eq!(chosen.target(&patch), Some(LayoutRef::Layout(first)));
    }

    #[test]
    fn a_background_for_a_removed_layout_is_left_out() {
        let mut patch = Patch::default();
        let first = patch.add_layout();
        patch.add_layout();
        let name = patch.layouts[first].name.clone();
        let chosen = picked(LayoutRef::Layout(first), &name);

        patch.remove_layout(first);

        assert_eq!(chosen.target(&patch), None);
    }

    #[test]
    fn canvas_mode_follows_the_selection() {
        let mut state = EditorState::default();
        assert_eq!(state.canvas_mode(), CanvasMode::Topology);
        state.select_layout(LayoutRef::Layout(0), 0);
        assert_eq!(state.canvas_mode(), CanvasMode::Layout);
    }
}