gled-ui 2.34.0

gled's egui interface, shared by the desktop app and the browser
Documentation
use crate::canvas::box_scale::drag_handle;
use crate::canvas::caches::Caches;
use crate::canvas::definition_drawing::{
    EDITOR_PIXEL, MIN_FIXTURE_SCREEN_PX, PixelTint, Placement, Style, draw_definition, draw_photo,
    fixture_footprint,
};
use crate::canvas::rotated_rect::RotatedRect;
use crate::canvas::scrollbars::panning;
use crate::canvas::shapes::{Shapes, draw_edge};
use crate::fixture_editor::drag::FixtureEnd;
use crate::fixture_editor::node_response::NodeResponse;
use crate::fixture_editor::port_inspector::port_label;
use egui::emath::{Rot2, TSTransform};
use egui::epaint::RectShape;
use egui::epaint::{Color32, FontFamily, PathShape};
use egui::{Align2, FontId, Painter, PointerButton, Pos2, Rect, Response, Stroke, Ui, vec2};
use egui_phosphor_icons::icons;
use gled_model::patching::definition::FixtureDefinition;
use gled_model::patching::fixture::{FixtureId, LayoutPosition};
use gled_model::patching::topology::Port;

const PORT_DIAMETER: f32 = 20.0;
/// How far beyond the input end the network attachment sits, in scene units.
const NETWORK_ATTACH_DISTANCE: f32 = 100.0;

pub(crate) struct FixtureUI<'a> {
    fixture: FixtureId,
    position: &'a LayoutPosition,
    definition: &'a FixtureDefinition,
    definition_id: Option<uuid::Uuid>,
    color: Color32,
    /// What the fixture covers, in scene units.
    footprint: RotatedRect,
    has_focus: bool,
    attach_network: bool,
    port: Option<Port>,
    port_selected: bool,
    to_global: TSTransform,
}

impl<'a> FixtureUI<'a> {
    pub(crate) fn new(
        fixture: FixtureId,
        position: &'a LayoutPosition,
        definition: &'a FixtureDefinition,
        definition_id: Option<uuid::Uuid>,
        color: Color32,
        has_focus: bool,
        attach_network: bool,
        to_global: TSTransform,
    ) -> Self {
        let footprint = fixture_footprint(
            definition,
            position,
            to_global.scaling,
            MIN_FIXTURE_SCREEN_PX,
        );
        FixtureUI {
            fixture,
            position,
            definition,
            definition_id,
            color,
            footprint,
            has_focus,
            attach_network,
            port: None,
            port_selected: false,
            to_global,
        }
    }

    /// The chain's network output, when this fixture is the chain head.
    pub(crate) fn with_port(mut self, port: Option<Port>, selected: bool) -> Self {
        self.port = port;
        self.port_selected = selected;
        self
    }

    /// DMX in, at the fixture's near end — signal runs along the tube, so
    /// both terminals sit on its length axis rather than its sides.
    pub(crate) fn input_terminal(&self) -> Pos2 {
        self.to_global * self.footprint.along_axis(0.0)
    }

    /// What the fixture covers, on screen.
    fn screen_footprint(&self) -> RotatedRect {
        self.footprint.transformed(self.to_global)
    }

    /// Show the fixture body. `single_selected` controls whether the per-fixture
    /// scale handles appear — with several fixtures selected the bounding-box
    /// handles replace them. `pointer_was_down` carries the editor's memory of a
    /// press that egui has not yet resolved into a click or a drag.
    pub(crate) fn show(
        mut self,
        ui: &mut Ui,
        node_painter: &Painter,
        overlay_painter: &mut Shapes,
        edge_painter: &mut Shapes,
        canvas_response: Response,
        single_selected: bool,
        pointer_was_down: &mut bool,
        show_handles: bool,
        caches: &mut Caches,
    ) -> NodeResponse {
        self.draw_body(node_painter, caches);

        let to_scene = self.to_global.inverse();
        let (pointer, press) = ui.input(|i| (i.pointer.interact_pos(), i.pointer.press_origin()));
        let on_fixture =
            |at: Option<Pos2>| at.is_some_and(|at| self.footprint.contains(to_scene * at));
        let hovering = on_fixture(pointer);
        // A press and a drag belong to the fixture under the press. egui calls
        // it a drag only once the pointer has moved, and a slow frame can
        // bring the press and the pointer already over another fixture.
        let pressed = on_fixture(press);
        let screen = self.screen_footprint();

        // check if egui decided on dragged or clicked if the mouse was formerly pressed down
        let node_response =
            if *pointer_was_down && (canvas_response.clicked() || canvas_response.dragged()) {
                *pointer_was_down = false;
                NodeResponse::from_response(&canvas_response, &screen, false, hovering, pressed)
            }
            // otherwise check if the mouse is currently pressed down on the fixture
            else if hovering || pressed {
                if pressed && canvas_response.is_pointer_button_down_on() {
                    *pointer_was_down = true;
                }
                NodeResponse::from_response(
                    &canvas_response,
                    &screen,
                    *pointer_was_down,
                    hovering,
                    pressed,
                )
            } else {
                NodeResponse::default()
            };

        if self.has_focus {
            overlay_painter.add(PathShape::convex_polygon(
                screen.corners().to_vec(),
                Color32::TRANSPARENT,
                Stroke::new(3.0, Color32::WHITE),
            ));
        }

        let mut node_response = node_response;
        if self.has_focus && single_selected && show_handles {
            // A definition lays its pixels out along its first dimension, so the
            // handles sit on that axis and stretch it — not across it.
            for end in [FixtureEnd::Near, FixtureEnd::Far] {
                if self.end_handle(ui, end, screen.along_axis(end.along()), overlay_painter) {
                    node_response.end_drag = Some(end);
                }
            }
        }

        if self.attach_network {
            let response = self.network_output(ui, node_painter, edge_painter);
            node_response.network_clicked = response.clicked();
        }

        node_response
    }

    /// The photo, faint, then the outline model tinted by the fixture's
    /// resolved mode, with the pixel shapes drawn black on top. Pixels sharing
    /// a model are batched into one mesh, so a 64-pixel bar costs one shape
    /// rather than sixty-four. A fixture with no usable model falls back to
    /// its footprint polygon so it stays visible and selectable.
    fn draw_body(&self, painter: &Painter, caches: &mut Caches) {
        let placement = Placement {
            position: self.position,
            to_global: self.to_global,
            min_screen_px: MIN_FIXTURE_SCREEN_PX,
        };
        let style = Style {
            outline: self.color,
            pixels: PixelTint::Uniform(EDITOR_PIXEL),
        };
        draw_photo(painter, self.definition, &placement, caches);
        draw_definition(
            painter,
            self.definition,
            self.definition_id,
            &placement,
            style,
            caches,
        );
    }

    /// The handle on one end of the fixture, at `centre` on screen. Returns
    /// whether a drag started on it, which turns and stretches the fixture
    /// about its other end.
    fn end_handle(
        &self,
        ui: &mut Ui,
        end: FixtureEnd,
        centre: Pos2,
        overlay_painter: &mut Shapes,
    ) -> bool {
        let id = ui.id().with(("fixture scale handle", self.fixture, end));
        drag_handle(ui, overlay_painter, centre, Color32::WHITE, id)
            .drag_started_by(PointerButton::Primary)
            && !panning(ui)
    }

    pub(crate) fn node_port(
        &mut self,
        ui: &mut Ui,
        node_painter: &Painter,
        next_connected: bool,
    ) -> Response {
        let to_local = ui
            .ctx()
            .layer_transform_from_global(ui.layer_id())
            .unwrap_or_default();
        let position = self.output_terminal();
        let rect_size = PORT_DIAMETER * to_local.scaling;

        let rect = Rect::from_center_size(to_local * position, (rect_size, rect_size).into());
        node_painter.add(
            RectShape::filled(
                Rect::from_center_size(
                    self.to_global * rect.center(),
                    vec2(PORT_DIAMETER, PORT_DIAMETER),
                ),
                20.0,
                Color32::from_black_alpha(100),
            )
            .with_blur_width(15.0),
        );
        let response = ui.interact(
            rect,
            ui.id().with(("fixture output port", self.fixture)),
            egui::Sense::drag(),
        );

        let (icon, family, color) = match (response.hovered(), response.dragged(), next_connected) {
            // remove the connection to the next fixture
            (true, false, true) => (icons::X.as_str(), "phosphor-regular", Color32::RED),
            // indicate that a connection can be made
            (true, false, _) => (icons::PLUGS.as_str(), "phosphor-fill", Color32::LIGHT_GREEN),
            // currently dragging a connection
            (_, true, _) => (icons::PLUGS.as_str(), "phosphor-fill", Color32::LIGHT_GREEN),
            // default state if connected
            (false, _, true) => (
                icons::PLUGS_CONNECTED.as_str(),
                "phosphor-fill",
                Color32::from_gray(150),
            ),
            // default state, if not connected
            (false, _, _) => (
                icons::PLUG.as_str(),
                "phosphor-fill",
                Color32::from_gray(150),
            ),
        };
        node_painter.text(
            self.to_global * rect.center(),
            Align2::CENTER_CENTER,
            icon,
            FontId::new(16.0, FontFamily::Name(family.into())),
            color,
        );

        response
    }

    /// DMX out, just past the fixture's far end, where the next tube plugs in.
    ///
    /// The offset is a constant screen distance: far enough that the port's
    /// hit area clears the body at any zoom, close enough to read as
    /// belonging to it.
    pub(crate) fn output_terminal(&self) -> Pos2 {
        self.to_global * self.footprint.along_axis(1.0)
            + Rot2::from_angle(self.position.angle) * vec2(PORT_DIAMETER * 0.6, 0.0)
    }

    /// Where the chain meets the network. It carries the universe it starts on
    /// — and the DMX channel too, when that is not the start of the universe —
    /// so the addressing is readable off the canvas. Clicking it selects it.
    fn network_output(
        &mut self,
        ui: &mut Ui,
        node_painter: &Painter,
        edge_painter: &mut Shapes,
    ) -> Response {
        let input_size = vec2(38.0, 26.0);
        let input = self.input_terminal();
        // the network feeds the input end, so it sits beyond that end
        let attach_network_position = input
            + self.to_global.scaling
                * (Rot2::from_angle(self.position.angle) * vec2(-NETWORK_ATTACH_DISTANCE, 0.0));
        let source_rect = Rect::from_center_size(attach_network_position, input_size);
        node_painter.rect_filled(source_rect, 4.0, Color32::BLUE);
        if self.port_selected {
            node_painter.rect_stroke(
                source_rect.expand(2.0),
                6.0,
                Stroke::new(2.0, Color32::WHITE),
                egui::StrokeKind::Outside,
            );
        }

        let label = self.port.map(port_label).unwrap_or_default();
        node_painter.text(
            source_rect.center(),
            Align2::CENTER_CENTER,
            label,
            FontId::proportional(13.0),
            Color32::WHITE,
        );

        let edge_begin_position =
            attach_network_position + (Rot2::from_angle(self.position.angle) * vec2(15.0, 0.0));
        let edge_end_position = input + (Rot2::from_angle(self.position.angle) * vec2(-10.0, 0.0));
        draw_edge(edge_painter, &edge_begin_position, &edge_end_position);

        let to_local = ui
            .ctx()
            .layer_transform_from_global(ui.layer_id())
            .unwrap_or_default();
        ui.interact(
            to_local * source_rect,
            ui.id().with(("network output", self.fixture)),
            egui::Sense::click(),
        )
    }
}

#[cfg(test)]
mod tests;