gled-ui 2.31.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! What the fixture definition editor remembers between frames. All of it is
//! volatile; the definition itself lives in the asset tree's selection.

use crate::canvas::box_scale::BoxScale;
use crate::canvas::box_select::Band;
use crate::canvas::caches::Caches;
use crate::canvas::rotate_handle::RotateDrag;
use crate::canvas::view::SceneView;
use crate::fixture_definition_editor::canvas_geometry::{origin, outgrows, scene_side};
use crate::fixture_definition_editor::generators::{GeneratorKind, GeneratorParams};
use crate::fixture_definition_editor::picked_file::{DefinitionFile, PickedFile};
use crate::fixture_definition_editor::shape::DrawState;
use crate::ui::file_pick::{FileFilter, FilePick};
use egui::Pos2;
use gled_model::patching::definition::FixtureDefinition;
use std::collections::BTreeSet;

/// A pointer drag on the canvas, anchored on how things were when it began.
#[derive(Debug, Clone)]
pub(crate) enum Drag {
    /// Moving the selected pixels: where each was, and where the pointer was.
    Move {
        pointer_start: Pos2,
        start: Vec<(usize, [f32; 3])>,
    },
    /// A rubber band over the pixels.
    Band(Band<usize>),
    /// Scaling the selection by a bounding-box handle.
    Scale(BoxScale<(usize, [f32; 3])>),
    /// Turning the selection about the centre of its box: each pixel's
    /// position and rotation when the drag started.
    Rotate(RotateDrag<(usize, [f32; 3], f32)>),
}

/// The editor's working state.
pub(crate) struct EditorState {
    /// The asset this state belongs to. Another selection in the tree starts
    /// afresh.
    pub(crate) editing: Option<uuid::Uuid>,
    pub(crate) selection: BTreeSet<usize>,
    pub(crate) view: SceneView,
    /// The side of the square scene, in scene units.
    pub(crate) scene_side: f32,
    /// The zoom the canvas was last drawn at, so the scene can grow without
    /// the view zooming out.
    pub(crate) zoom: f32,
    /// Where the canvas was last drawn, on screen; the generator forms sit in
    /// its corner.
    pub(crate) canvas_rect: egui::Rect,
    pub(crate) drag: Option<Drag>,
    /// The mode whose groups the canvas colours and the test screen lights.
    pub(crate) mode: usize,
    /// The model new pixels are drawn with.
    pub(crate) current_model: usize,
    /// The generator whose form is open, if any.
    pub(crate) generator: Option<GeneratorKind>,
    pub(crate) params: GeneratorParams,
    /// The drawing tool and the shape it is drawing.
    pub(crate) draw: DrawState,
    pub(crate) caches: Caches,
    /// The file chooser, for a photo, a model or a path.
    pub(crate) file_pick: FilePick<PickedFile>,
    /// What went wrong with the last action, shown until the next one.
    pub(crate) message: Option<String>,
    /// A text field had the keyboard when the frame began. Enter or Escape
    /// then only ends the typing; it does not also add or cancel.
    pub(crate) typing: bool,
}

impl Default for EditorState {
    fn default() -> Self {
        Self {
            editing: None,
            selection: BTreeSet::new(),
            view: SceneView::centred_on(origin(0.0)),
            scene_side: 0.0,
            zoom: 0.0,
            canvas_rect: egui::Rect::NOTHING,
            drag: None,
            mode: 0,
            current_model: 0,
            generator: None,
            params: GeneratorParams::default(),
            draw: DrawState::default(),
            caches: Caches::default(),
            file_pick: FilePick::default(),
            message: None,
            typing: false,
        }
    }
}

impl std::fmt::Debug for EditorState {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("EditorState")
            .field("editing", &self.editing)
            .field("selection", &self.selection)
            .field("mode", &self.mode)
            .finish_non_exhaustive()
    }
}

impl EditorState {
    /// Starts over on another definition, keeping the caches and the
    /// generator settings.
    pub(crate) fn reset_for(&mut self, id: Option<uuid::Uuid>, definition: &FixtureDefinition) {
        self.editing = id;
        self.selection.clear();
        self.drag = None;
        self.mode = 0;
        self.current_model = 0;
        self.generator = None;
        self.draw.shape = None;
        self.message = None;
        self.fit_view(definition);
    }

    /// Sizes the scene to the definition and shows all of it.
    pub(crate) fn fit_view(&mut self, definition: &FixtureDefinition) {
        self.scene_side = scene_side(definition);
        self.view = SceneView::centred_on(origin(self.scene_side));
    }

    /// Keeps everything the edit left behind valid: the selection, the mode
    /// and the model point at things that exist, and the scene grows when the
    /// definition does — moving the view along, so nothing on screen jumps —
    /// once no drag is in flight.
    pub(crate) fn clamp(&mut self, definition: &FixtureDefinition) {
        let pixel_count = definition.pixels.len();
        self.selection.retain(|pixel| *pixel < pixel_count);
        if self.mode >= definition.modes.len() {
            self.mode = 0;
        }
        if self.current_model >= definition.models.len() {
            self.current_model = 0;
        }
        // Not during a drag: it is anchored in scene units, and moving the
        // origin under it would feed back into the drag, frame after frame.
        if self.drag.is_none() && outgrows(definition, self.scene_side) {
            // a fitted view would fit the bigger scene, and everything shrink
            if self.view.zoom.is_none() && self.zoom > 0.0 {
                self.view.zoom = Some(self.zoom);
            }
            let before = origin(self.scene_side);
            self.scene_side = scene_side(definition);
            self.view.center += origin(self.scene_side) - before;
        }
    }

    /// Opens the file chooser for the definition being edited, and has
    /// `load` read the chosen file on the chooser's thread.
    pub(crate) fn ask_file(
        &mut self,
        ctx: &egui::Context,
        filter: FileFilter,
        load: fn(std::path::PathBuf) -> DefinitionFile,
    ) {
        let definition = self.editing;
        self.file_pick.ask(ctx, filter, move |path| PickedFile {
            definition,
            file: load(path),
        });
    }

    /// The fixture's origin in the current scene.
    pub(crate) fn origin(&self) -> Pos2 {
        origin(self.scene_side)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use gled_model::patching::definition::{EmittedColor, Pixel};

    fn reaching(x_m: f32) -> FixtureDefinition {
        FixtureDefinition {
            pixels: vec![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],
            }],
            ..Default::default()
        }
    }

    /// Regression: a box scale grew the scene every frame, which moved the
    /// origin under the drag's anchors and stretched the pixels kilometres
    /// away.
    #[test]
    fn the_scene_waits_for_a_drag_to_end_before_it_grows() {
        let mut state = EditorState::default();
        state.fit_view(&reaching(0.1));
        let side = state.scene_side;
        state.drag = Some(Drag::Move {
            pointer_start: Pos2::ZERO,
            start: vec![],
        });

        state.clamp(&reaching(2.0));
        assert_eq!(state.scene_side, side);

        state.drag = None;
        state.zoom = 3.0;
        state.clamp(&reaching(2.0));
        assert!(state.scene_side > side);
        assert_eq!(state.view.zoom, Some(3.0), "the zoom stays as it was");
    }
}