gled-ui 2.32.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! The patch canvas: the selected layout's fixtures, with the background
//! image behind them, and the context menu over them.

use crate::canvas::frame::{CanvasSpec, show_canvas};
use crate::canvas::shapes::{Shapes, draw_adaptive_grid};
use crate::canvas::{images::decode_picture, pictures};
use crate::fixture_editor::commands::context_actions;
use crate::fixture_editor::drag::{Drag, drag_positions};
use crate::fixture_editor::fixtures::draw_fixtures;
use crate::fixture_editor::selection_handles::covered_by;
use crate::fixture_editor::state::{EditorState, PatchEdit, PickedBackground};
use crate::ui::file_pick::IMAGES;
use egui::emath::TSTransform;
use egui::{Color32, Rect, Sense, Ui, pos2};
use egui::{Modifiers, Pos2, Stroke, StrokeKind};
use gled_model::asset::picture::new_picture_path;
use gled_model::collection::Collections;
use gled_model::patching::Patch;
use gled_model::patching::definition::FixtureDefinition;
use gled_model::patching::fixture::FixtureId;
use gled_model::patching::layout::LayoutRef;
use gled_model::patching::layout::scene_bounds;
use std::collections::BTreeMap;
use std::sync::LazyLock;

/// Stands in for a fixture whose definition is missing, so it can still be
/// selected and repaired instead of vanishing from the canvas.
pub(super) static UNPATCHED: LazyLock<FixtureDefinition> =
    LazyLock::new(FixtureDefinition::default);

/// Definitions of every fixture drawn this frame, resolved once.
pub(super) type Definitions =
    BTreeMap<FixtureId, std::sync::Arc<gled_model::asset::Asset<FixtureDefinition>>>;

pub(super) fn definition_of(definitions: &Definitions, id: FixtureId) -> &FixtureDefinition {
    definitions
        .get(&id)
        .map(|asset| &asset.data)
        .unwrap_or(&UNPATCHED)
}

/// The patch canvas: the selected layout's fixtures, wired in the topology,
/// to select, move, scale, turn and connect.
pub(crate) fn show(
    ui: &mut Ui,
    edit: &mut PatchEdit<'_>,
    state: &mut EditorState,
    collections: &Collections,
) {
    let drawn = state.selection;
    let mode = state.canvas_mode();

    if let Some(picked) = state.background_pick.take() {
        set_background(ui.ctx(), edit, state, picked);
    }

    let background = edit
        .value
        .layout(drawn)
        .and_then(|layout| layout.background.clone());
    let texture = background
        .as_deref()
        .and_then(|path| state.caches.images.texture(ui.ctx(), path));

    // The picker floats over this canvas; letting the canvas answer a
    // click meant for the picker selects a fixture behind it.
    let active = !state.picking_members;

    let mut view = state.view;
    let spec = CanvasSpec {
        id_salt: "editor_ui",
        // Zooming out stops once the whole scene fits: the live preview
        // maps exactly the scene square onto its texture, so anything the
        // editor showed beyond it would not be true to the preview.
        scene_size: scene_bounds().size(),
        max_zoom: 8.0,
        sense: if active {
            Sense::click_and_drag()
        } else {
            Sense::hover()
        },
    };
    show_canvas(ui, spec, &mut view, |canvas| {
        if active {
            context_actions(edit, state, collections, mode, &canvas.response);
        }

        if let Some(texture) = &texture {
            canvas.ui.painter().image(
                texture.id(),
                scene_bounds(),
                Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
                Color32::from_gray(200),
            );
        }

        draw_fixtures(canvas, edit, state, collections, mode, drawn, active);
    });
    state.view = view;
}

/// Opens the file chooser for the selected layout's background image. The
/// image is decoded on the chooser's thread.
pub(crate) fn ask_background(ctx: &egui::Context, patch: &Patch, state: &mut EditorState) {
    let layout = state.selection;
    let name = patch
        .layout(layout)
        .map(|layout| layout.name.clone())
        .unwrap_or_default();
    state.background_pick.ask(ctx, IMAGES, move |file| {
        let path = new_picture_path(&file.extension());
        let image = decode_picture(&path, &file.bytes);
        PickedBackground {
            layout,
            name,
            path,
            bytes: file.bytes,
            image,
        }
    });
}

/// Makes a chosen image the background of the layout it was chosen for. One
/// that could not be decoded is left out: a background nobody can see is no
/// use. So is one whose layout is gone, or can no longer be told apart.
fn set_background(
    ctx: &egui::Context,
    edit: &mut PatchEdit<'_>,
    state: &mut EditorState,
    picked: PickedBackground,
) {
    let target = picked.target(edit.value);
    let PickedBackground {
        path, bytes, image, ..
    } = picked;
    let decoded = image.is_ok();
    state.caches.images.insert(ctx, path.clone(), image);
    let Some(layout) = target else {
        tracing::warn!(
            "Left out {}: the layout it was chosen for is gone",
            path.display()
        );
        return;
    };
    if decoded {
        pictures::store(path.clone(), bytes);
        edit.apply(|patch| {
            if let Some(layout) = patch.layout_mut(layout) {
                layout.background = Some(path);
            }
        });
    }
}

/// Carries the drag in flight to `pointer`, in scene units.
pub(super) fn continue_drag(
    edit: &mut PatchEdit<'_>,
    state: &mut EditorState,
    drawn: LayoutRef,
    definitions: &Definitions,
    pointer: Pos2,
    modifiers: Modifiers,
    zoom: f32,
) {
    if let Some(Drag::Band(band)) = &mut state.drag {
        band.current = pointer;
        let covered = covered_by(edit.value, drawn, band.rect(), definitions, zoom);
        let selection = band.selection(covered);
        state.select_fixtures(selection);
        return;
    }
    if let Some(drag) = &state.drag {
        edit.continue_change(|patch| {
            if let Some(layout) = patch.layout_mut(drawn) {
                drag_positions(drag, layout, pointer, modifiers, |id| {
                    definition_of(definitions, id).scaling
                });
            }
        });
    }
}

/// Grid and border of the scene square. The border is what makes the editor's
/// rectangle visible, and it is the same rectangle the live preview shows.
pub(super) fn draw_layered_grid(edge_painter: &mut Shapes, scene_transform: &TSTransform) {
    let rect = scene_bounds();
    draw_adaptive_grid(
        edge_painter,
        rect,
        scene_transform,
        &[
            (10.0, Stroke::new(0.3, Color32::from_white_alpha(30))),
            (100.0, Stroke::new(0.4, Color32::from_white_alpha(60))),
        ],
    );
    edge_painter.rect_stroke(
        Rect::from_min_max(*scene_transform * rect.min, *scene_transform * rect.max),
        0.0,
        Stroke::new(1.0, Color32::from_white_alpha(110)),
        StrokeKind::Outside,
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::canvas::undo::UndoStack;
    use egui::{Event, PointerButton, RawInput, vec2};
    use gled_model::asset::Asset;
    use gled_model::asset_id::AssetId;
    use gled_model::collection::Collection;
    use gled_model::patching::fixture::{Fixture, LayoutPosition};

    /// The patch canvas on an 800 × 600 screen, driven by pointer events.
    struct Session {
        ctx: egui::Context,
        patch: Patch,
        undo: UndoStack<Patch>,
        state: EditorState,
        collections: Collections,
    }

    impl Session {
        /// One 4 m square fixture in the middle of the scene, which puts it
        /// round the middle of the screen.
        fn new() -> (Self, FixtureId) {
            let asset = Asset {
                id: AssetId::default(),
                path: vec!["square".to_string()],
                data: FixtureDefinition {
                    size_m: [4.0, 4.0, 0.1],
                    ..Default::default()
                },
            };
            let mut fixture = Fixture::new("square");
            fixture.definition = Some(asset.id);
            let mut collection = Collection::default();
            collection.set_asset(asset);
            let mut collections = Collections::default();
            collections.insert::<FixtureDefinition>(collection);
            let mut patch = Patch::default();
            let id = patch.add_fixture(fixture, LayoutPosition::at(scene_bounds().center()));
            // the canvas writes with gled's icon fonts
            let ctx = egui::Context::default();
            crate::style::apply(&ctx);
            let session = Self {
                ctx,
                patch,
                undo: UndoStack::default(),
                state: EditorState::default(),
                collections,
            };
            (session, id)
        }

        fn frame(&mut self, events: Vec<Event>) {
            let input = RawInput {
                screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
                events,
                ..Default::default()
            };
            let mut output = self.ctx.run_ui(input, |ui| {
                let mut edit = PatchEdit::new(&mut self.patch, &mut self.undo);
                show(ui, &mut edit, &mut self.state, &self.collections);
            });
            output.textures_delta.clear();
        }

        /// Presses `button` at `from`, drags to `to` over a few frames and
        /// lets go.
        fn drag(&mut self, button: PointerButton, from: Pos2, to: Pos2) {
            let press = |pos, pressed| Event::PointerButton {
                pos,
                button,
                pressed,
                modifiers: Modifiers::NONE,
            };
            self.frame(vec![Event::PointerMoved(from)]);
            self.frame(vec![press(from, true)]);
            for step in 1..=4 {
                self.frame(vec![Event::PointerMoved(
                    from + (to - from) * step as f32 / 4.0,
                )]);
            }
            self.frame(vec![press(to, false)]);
            self.frame(vec![]);
        }

        fn position(&self, id: FixtureId) -> Pos2 {
            self.patch.topology.default_layout.members[&id].pos
        }
    }

    const MIDDLE: Pos2 = Pos2::new(395.0, 295.0);

    /// What the other tests rely on: a primary drag on the fixture moves it,
    /// as one undo step.
    #[test]
    fn a_primary_drag_moves_the_fixture() {
        let (mut session, id) = Session::new();
        session.frame(vec![]);

        session.drag(PointerButton::Primary, MIDDLE, MIDDLE + vec2(40.0, 0.0));

        assert!(session.position(id).x > scene_bounds().center().x);
        assert!(session.undo.can_undo());
    }

    /// Regression: any button started a move, so a right-drag on a fixture
    /// jumped it along by the drag's first step and left an undo step.
    #[test]
    fn a_secondary_drag_moves_nothing() {
        let (mut session, id) = Session::new();
        session.frame(vec![]);

        session.drag(PointerButton::Secondary, MIDDLE, MIDDLE + vec2(40.0, 0.0));

        assert_eq!(session.position(id), scene_bounds().center());
        assert!(!session.undo.can_undo());
    }

    /// Regression: a right-drag on the background started a rubber band,
    /// which dropped the selection.
    #[test]
    fn a_secondary_drag_on_the_background_keeps_the_selection() {
        let (mut session, id) = Session::new();
        session.state.select_single(id);
        session.frame(vec![]);

        session.drag(
            PointerButton::Secondary,
            pos2(150.0, 500.0),
            pos2(200.0, 550.0),
        );

        assert!(session.state.selected_fixtures.contains(&id));
    }

    /// Regression: a band only replaced the fixture selection, so with a
    /// network output selected the inspector went on showing the output
    /// instead of the fixtures the band had just selected.
    #[test]
    fn a_band_over_fixtures_leaves_the_network_output() {
        let (mut session, id) = Session::new();
        session.state.select_port(id);
        session.frame(vec![]);

        session.drag(
            PointerButton::Primary,
            pos2(150.0, 500.0),
            MIDDLE + vec2(20.0, -20.0),
        );

        assert!(session.state.selected_fixtures.contains(&id));
        assert_eq!(session.state.selected_port, None);
    }
}