gled-ui 2.33.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! The layout stack in the patch editor's left panel: the topology, then
//! every layout with its thumbnail, to pick, rename, add, copy and remove.

use crate::canvas::definition_drawing::fixture_footprint;
use crate::canvas::view::fit_scene_square;
use crate::fixture_editor::FixtureWindow;
use crate::fixture_editor::NAME_CHAR_LIMIT;
use crate::fixture_editor::canvas::UNPATCHED;
use crate::fixture_editor::state::{EditorState, PatchEdit};
use egui::emath::Align;
use egui::epaint::PathShape;
use egui::epaint::{Margin, StrokeKind};
use egui::{Color32, Frame, Id, Layout, ScrollArea, Sense, TextEdit, Ui, UiBuilder};
use egui::{Rect, Stroke, pos2};
use egui_phosphor_icons::icons;
use gled_model::collection::Collections;
use gled_model::patching::Patch;
use gled_model::patching::layout::scene_bounds;
use gled_model::patching::layout::{Layout as PatchLayout, LayoutRef};

/// Size of the icons on the layout stack's buttons.
const TOOL_ICON_SIZE: f32 = 22.0;

impl FixtureWindow {
    pub(super) fn layout_stack(
        &mut self,
        ui: &mut Ui,
        edit: &mut PatchEdit<'_>,
        collections: &Collections,
    ) {
        egui::CentralPanel::default().show(ui, |ui| {
            // The topology is the rig itself, not one of the layouts: it cannot
            // be renamed, copied or deleted, so it sits above the stack rather
            // than in it.
            self.stack_entry(ui, edit, LayoutRef::Topology, collections);
            ui.add_space(6.0);
            ui.label(
                egui::RichText::new("Layouts")
                    .small()
                    .color(Color32::from_gray(150)),
            );

            let response = ui
                .scope_builder(
                    UiBuilder::new()
                        .id(Id::new("layout stack"))
                        .sense(Sense::click()),
                    |ui| {
                        Frame::new()
                            .fill(Color32::from_gray(30))
                            .outer_margin(0.0)
                            .corner_radius(
                                ui.ctx().global_style().visuals.widgets.active.corner_radius,
                            )
                            .show(ui, |ui| {
                                ui.take_available_space();
                                ScrollArea::new([false, true]).show(ui, |ui| {
                                    for index in 0..edit.value.layouts.len() {
                                        self.stack_entry(
                                            ui,
                                            edit,
                                            LayoutRef::Layout(index),
                                            collections,
                                        );
                                    }
                                });
                                self.layout_button_bar(ui, edit);
                            })
                    },
                )
                .response;
            // clicking the background deselects the fixtures, not the layout —
            // a layout is always selected
            if response.clicked() {
                self.state.selected_fixtures.clear();
            }
        });
    }

    /// One entry in the stack: a layout and its preview. Modes live in the top
    /// bar, beside the canvas they change.
    fn stack_entry(
        &mut self,
        ui: &mut Ui,
        edit: &mut PatchEdit<'_>,
        layout_ref: LayoutRef,
        collections: &Collections,
    ) {
        let patch: &Patch = edit.value;
        let selected = self.state.selection == layout_ref;
        let is_topology = layout_ref == LayoutRef::Topology;

        let Some(layout) = patch.layout(layout_ref) else {
            return;
        };
        let mut name = layout.name.clone();
        let mut rename = None;

        // A globally scoped id: adding or removing a layout must not renumber
        // the widgets of the entries around it.
        let scope = ui.scope_builder(
            UiBuilder::new()
                .id(Id::new(("layout stack entry", layout_ref)))
                .sense(Sense::click()),
            |ui| {
                // The topology is a different kind of thing from a layout, so
                // it does not look like one of the stack's entries.
                let fill = match (is_topology, selected) {
                    (true, true) => Color32::from_rgb(70, 90, 130),
                    (true, false) => Color32::from_rgb(48, 60, 86),
                    (false, true) => Color32::from_gray(120),
                    (false, false) => Color32::from_gray(80),
                };
                Frame::new()
                    .fill(fill)
                    .corner_radius(ui.ctx().global_style().visuals.widgets.active.corner_radius)
                    .inner_margin(5.0)
                    .outer_margin(Margin {
                        left: 1,
                        top: 1,
                        right: 1,
                        bottom: 0,
                    })
                    .show(ui, |ui| {
                        ui.take_available_width();
                        ui.horizontal(|ui| {
                            if is_topology {
                                ui.label(icons::NETWORK.fill());
                                ui.label(PatchLayout::DEFAULT_NAME).on_hover_text(
                                    "The rig itself: every fixture, and how they are wired",
                                );
                            } else {
                                // only the selected layout's name can be changed
                                let mut field = TextEdit::singleline(&mut name)
                                    .char_limit(NAME_CHAR_LIMIT)
                                    .desired_width(100.0)
                                    .background_color(Color32::TRANSPARENT);
                                if !selected {
                                    field = field.frame(egui::Frame::NONE).interactive(false);
                                }
                                let response = field.show(ui).response;
                                rename = selected.then_some(response);
                            }
                            ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
                                Frame::new().show(ui, |ui| {
                                    ui.set_height(50.0);
                                    ui.set_width(50.0);
                                    let rect = ui.available_rect_before_wrap();
                                    draw_preview(
                                        ui,
                                        patch,
                                        &mut self.state,
                                        layout_ref,
                                        rect,
                                        collections,
                                    );
                                });
                            });
                        });
                    });
            },
        );

        if let Some(response) = rename {
            edit.tracked(&response, |patch| {
                if let Some(layout) = patch.layout_mut(layout_ref) {
                    layout.name = name;
                }
            });
        }

        let resp = &scope.response;
        if resp.hovered() && !selected {
            ui.painter().rect_stroke(
                resp.rect,
                ui.ctx().global_style().visuals.widgets.active.corner_radius,
                ui.ctx().global_style().visuals.widgets.hovered.fg_stroke,
                StrokeKind::Inside,
            );
        }
        if resp.clicked() {
            self.state.select_layout(layout_ref, 0);
        }
    }

    fn layout_button_bar(&mut self, ui: &mut Ui, edit: &mut PatchEdit<'_>) {
        ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
            Frame::new()
                .fill(Color32::from_gray(35))
                .inner_margin(4.0)
                .corner_radius(ui.ctx().global_style().visuals.widgets.active.corner_radius)
                .show(ui, |ui| {
                    ui.take_available_width();

                    ui.with_layout(Layout::right_to_left(Align::Max), |ui| {
                        // the topology is not a user layout: it cannot be
                        // removed or duplicated
                        let selected = match self.state.selection {
                            LayoutRef::Layout(index) => Some(index),
                            LayoutRef::Topology => None,
                        };
                        ui.add_enabled_ui(selected.is_some(), |ui| {
                            if ui
                                .button(
                                    icons::TRASH.fill().color(Color32::RED).size(TOOL_ICON_SIZE),
                                )
                                .clicked()
                                && let Some(index) = selected
                            {
                                edit.apply(|patch| patch.remove_layout(index));
                                self.state.select_layout(LayoutRef::Topology, 0);
                            }

                            if ui
                                .button(icons::COPY_SIMPLE.fill().size(TOOL_ICON_SIZE))
                                .clicked()
                                && let Some(index) = selected
                                && let Some(new_index) =
                                    edit.apply(|patch| patch.duplicate_layout(index))
                            {
                                self.state.select_layout(LayoutRef::Layout(new_index), 0);
                            }
                        });

                        if ui
                            .button(icons::PLUS.regular().size(TOOL_ICON_SIZE))
                            .clicked()
                        {
                            let index = edit.apply(|patch| patch.add_layout());
                            self.state.select_layout(LayoutRef::Layout(index), 0);
                        }
                    });
                });
        });
    }
}

/// Smallest a fixture is drawn in a layout thumbnail.
const THUMBNAIL_MIN_PX: f32 = 1.5;

/// A lightweight preview of a layout: background image and fixture bodies only.
pub(crate) fn draw_preview(
    ui: &mut Ui,
    patch: &Patch,
    state: &mut EditorState,
    layout_ref: LayoutRef,
    preview_rect: Rect,
    collections: &Collections,
) {
    let painter = ui.painter_at(preview_rect);
    painter.rect_filled(preview_rect, 0.0, Color32::from_gray(25));

    let Some(layout) = patch.layout(layout_ref) else {
        return;
    };

    let scene_bounds = scene_bounds();
    let xform = fit_scene_square(preview_rect);

    if let Some(texture) = layout
        .background
        .as_deref()
        .and_then(|path| state.caches.images.texture(ui.ctx(), path))
    {
        painter.image(
            texture.id(),
            xform * scene_bounds,
            Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
            Color32::from_gray(200),
        );
    }

    for (id, position) in layout.members.iter() {
        let definition = patch.definition(*id, collections);
        let definition = definition.as_ref().map(|a| &a.data).unwrap_or(&UNPATCHED);
        // A thumbnail is a map, not a canvas: a fixture is a mark on it, and
        // the canvas's clickable minimum would swell every one into a blob.
        painter.add(PathShape::convex_polygon(
            fixture_footprint(definition, position, xform.scaling, THUMBNAIL_MIN_PX)
                .transformed(xform)
                .corners()
                .to_vec(),
            Color32::from_gray(190),
            Stroke::NONE,
        ));
    }
}