gled-ui 2.31.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! The patch editor: a window with the test screen, the inspector and the
//! layout stack on the left, and the canvas the selected layout is edited on.

mod canvas;
mod chains;
mod clipboard;
mod commands;
mod draft;
mod drag;
mod fixture_ui;
mod fixtures;
mod inspector;
mod layout_stack;
mod member_preview;
mod membership;
mod node_response;
mod port_inspector;
mod selection_handles;
mod state;
mod toolbar;
#[cfg(test)]
mod window_tests;

use crate::canvas::test_screen;
use crate::fixture_editor::membership::ListDrag;
use crate::fixture_editor::state::EditorState;
use crate::ui::file_pick::FilePick;
use crate::ui::window_common::{
    dialog_viewport_builder, embedded_close_clicked, gled_window_frame, raise, tool_window_builder,
};
use draft::{Draft, DraftStatus};
use egui::{Color32, Context, Id, Rangef, ScrollArea, Sense, Ui, Vec2, ViewportId};
use gled_model::asset::project::Project;
use gled_model::asset_id::AssetId;
use gled_model::collection::Collections;
use gled_model::patching::Patch;
use gled_model::patching::definition::FixtureDefinition;
use gled_model::patching::layout::LayoutSelection;
use state::PatchEdit;

/// Longest a layout or mode name can be typed.
const NAME_CHAR_LIMIT: usize = 24;
/// The ⚠ beside something the output cannot do as it is set.
const WARNING: Color32 = Color32::from_rgb(230, 160, 70);

/// Apply's fill while there is something to apply.
const APPLY_FILL: Color32 = Color32::from_rgb(58, 104, 68);

/// Hands the patch to the show. Green because it is the one button here that
/// reaches beyond the window, grey while there is nothing to hand over.
fn apply_button(enabled: bool) -> egui::Button<'static> {
    let fill = if enabled {
        APPLY_FILL
    } else {
        Color32::from_gray(45)
    };
    egui::Button::new("Apply").fill(fill)
}

#[derive(Default)]
pub(crate) struct FixtureWindow {
    open: bool,
    /// Set while the window still has to be brought forward.
    raise: bool,
    /// Set while the user is being asked what to do with unapplied changes.
    closing: bool,
    state: EditorState,
    /// A run of list rows being dragged between the membership lists.
    list_drag: Option<ListDrag>,
    draft: Option<Draft>,
    /// Set by Apply until `project_edits` has looked at the patch.
    patch_applied: bool,
    /// Set when the interface adopted the engine's project, until the draft
    /// was compared with its patch.
    show_patch_replaced: bool,
    /// The fixture definition editor was asked for, on this definition if
    /// any. The windows owning both editors hand it over.
    definitions_request: Option<Option<AssetId<FixtureDefinition>>>,
}

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

impl FixtureWindow {
    /// The window itself and the two dialogs that belong to it.
    fn viewport() -> ViewportId {
        ViewportId(Id::new("fixture editor window"))
    }

    fn picker_viewport() -> ViewportId {
        ViewportId(Id::new("fixture member picker"))
    }

    fn closing_viewport() -> ViewportId {
        ViewportId(Id::new("fixture editor closing"))
    }

    const TITLE: &str = "Gleditor";
    const CLOSING_TITLE: &str = "Unapplied changes";
    const PICKER_TITLE: &str = "Fixtures in this layout";

    /// What the test screen, the inspector and the layout stack each need.
    const TEST_SCREEN_MIN: f32 = 120.0;
    const INSPECTOR_MIN: f32 = 160.0;
    const LAYOUT_STACK_MIN: f32 = 160.0;

    /// Whether the fixture definition editor was asked for since the last
    /// call, and on which definition.
    pub(crate) fn take_definitions_request(
        &mut self,
    ) -> Option<Option<AssetId<FixtureDefinition>>> {
        self.definitions_request.take()
    }

    pub(crate) fn open(&mut self) {
        self.open = true;
        self.raise = true;
    }

    #[cfg_attr(feature = "profiling", profiling::function)]
    pub(crate) fn update(
        &mut self,
        ctx: &Context,
        project: &mut Option<Project>,
        collections: &Collections,
    ) {
        if !self.open {
            return;
        }

        ctx.show_viewport_immediate(
            Self::viewport(),
            tool_window_builder(Self::TITLE)
                .with_inner_size(Vec2::new(1200.0, 800.0))
                .with_min_inner_size(Vec2::new(660.0, 500.0)),
            |ctx, _viewport_class| {
                raise(ctx, &mut self.raise);
                self.state.caches.begin_frame();
                // what the toolbar is drawn with this frame
                let mut shown = None;

                gled_window_frame(ctx, Self::TITLE, |ui| {
                    if self.dialog().is_some() {
                        ui.disable();
                    }
                    let Some(project) = project.as_mut() else {
                        ui.centered_and_justified(|ui| ui.label("No project loaded"));
                        self.draft = None;
                        return;
                    };
                    // A draft taken from a patch the show has since replaced —
                    // another project was loaded — is not about this patch any
                    // more and is started over. Only the interface adopting
                    // the engine's project replaces the patch besides Apply,
                    // so it is compared only after that.
                    let replaced = std::mem::take(&mut self.show_patch_replaced);
                    let mut draft = match self
                        .draft
                        .take()
                        .filter(|draft| !replaced || draft.applied == project.patch)
                    {
                        Some(draft) => draft,
                        None => {
                            // an image chosen for the draft before is not
                            // about this one
                            self.state.background_pick = FilePick::default();
                            Draft::taken_from(&project.patch)
                        }
                    };
                    self.state.clamp(&draft.patch);
                    let status = draft.status();
                    shown = Some(status);
                    let apply = draft.edit(|edit| self.draw_window(ui, edit, collections, status));
                    if apply {
                        self.apply(&mut draft, project);
                    }
                    self.draft = Some(draft);
                });

                // Closing drops the draft, so changes nobody applied have to be
                // asked about first.
                if ctx.input(|input| input.viewport().close_requested())
                    || embedded_close_clicked(Self::TITLE)
                {
                    if self.unapplied() {
                        self.closing = true;
                        ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
                    } else {
                        self.close();
                    }
                }

                // The dialogs are windows of their own, so they are shown
                // beside this one rather than inside it.
                if self.state.picking_members
                    && let Some(mut draft) = self.draft.take()
                {
                    draft.edit(|edit| self.member_picker(ctx, edit, collections));
                    self.draft = Some(draft);
                }
                if self.closing {
                    self.ask_before_closing(ctx, project);
                }
                self.keep_dialogs_in_front(ctx);

                // Apply, undo and redo are drawn before the canvas and the
                // dialogs change the draft. A frame showing them as they were
                // stays on screen until the next one, which may not come
                // before the next input, so it is drawn again instead.
                if self.draft.as_ref().map(Draft::status) != shown {
                    ctx.request_discard("the patch editor's draft changed after its toolbar");
                }
            },
        );
    }

    /// The window's panels and canvas on `edit`. Returns whether the draft is
    /// to be handed to the show.
    fn draw_window(
        &mut self,
        ui: &mut Ui,
        edit: &mut PatchEdit<'_>,
        collections: &Collections,
        status: DraftStatus,
    ) -> bool {
        egui::Panel::left("editor left panel")
            .resizable(true)
            .default_size(250.0)
            .min_size(250.0)
            .max_size(600.0)
            .show(ui, |ui| {
                // Each part keeps its floor, so a divider can only
                // take room that is actually free and nothing is
                // pushed out of the window.
                let (test, inspector, stack) = Self::left_floors(ui.available_height());
                let room = ui.available_height();
                egui::Panel::top("layout test screen")
                    .resizable(true)
                    .default_size(200.0)
                    .size_range(Rangef::new(test, (room - inspector - stack).max(test)))
                    .show(ui, |ui| self.show_test_screen(ui, edit.value, collections));
                let room = ui.available_height();
                egui::Panel::top("fixture inspector")
                    .resizable(true)
                    .default_size(200.0)
                    .size_range(Rangef::new(inspector, (room - stack).max(inspector)))
                    .show(ui, |ui| self.show_inspector(ui, edit, collections));
                self.layout_stack(ui, edit, collections);
            });

        self.draw_editor(ui, edit, collections, status)
    }

    /// Hands the draft to the show: the interface's project takes it, and the
    /// edit goes to the engine with the next sync (`project_edits`), which
    /// derives the positions and universes from it.
    fn apply(&mut self, draft: &mut Draft, project: &mut Project) {
        draft.apply_to(project);
        self.patch_applied = true;
    }

    /// Whether Apply handed a patch to the interface's project since the last
    /// call, which `project_edits` then compares with the show's.
    pub(crate) fn take_patch_applied(&mut self) -> bool {
        std::mem::take(&mut self.patch_applied)
    }

    /// Notes that the interface adopted the engine's project, which may hold
    /// another patch than the one the draft was taken from.
    pub(crate) fn project_replaced(&mut self) {
        self.show_patch_replaced = true;
    }

    fn unapplied(&self) -> bool {
        self.draft.as_ref().is_some_and(Draft::unapplied)
    }

    /// Closes the window and forgets the draft with it.
    fn close(&mut self) {
        self.open = false;
        self.closing = false;
        self.state.pick_members(false);
        self.state.background_pick = FilePick::default();
        self.draft = None;
    }

    /// Which dialog of this window is open, if any.
    fn dialog(&self) -> Option<ViewportId> {
        if self.closing {
            Some(Self::closing_viewport())
        } else if self.state.picking_members {
            Some(Self::picker_viewport())
        } else {
            None
        }
    }

    /// A dialog blocks the window it belongs to: bringing that window forward
    /// hands the focus straight back to the dialog.
    fn keep_dialogs_in_front(&self, ctx: &Context) {
        if let Some(dialog) = self.dialog()
            && ctx.input(|input| input.viewport().focused.unwrap_or(false))
        {
            ctx.send_viewport_cmd_to(dialog, egui::ViewportCommand::Focus);
        }
    }

    /// The three ways out of a window with unapplied changes.
    fn ask_before_closing(&mut self, ctx: &Context, project: &mut Option<Project>) {
        ctx.show_viewport_immediate(
            Self::closing_viewport(),
            dialog_viewport_builder(Self::CLOSING_TITLE).with_inner_size(Vec2::new(420.0, 150.0)),
            |ctx, _viewport_class| {
                // the window's own close button is the same as cancelling
                if ctx.input(|input| input.viewport().close_requested())
                    || embedded_close_clicked(Self::CLOSING_TITLE)
                {
                    self.closing = false;
                }
                gled_window_frame(ctx, Self::CLOSING_TITLE, |ui| {
                    ui.add_space(6.0);
                    ui.label("The show is still running the patch as it was.");
                    ui.add_space(12.0);
                    ui.horizontal(|ui| {
                        if ui.button("Cancel").clicked() {
                            self.closing = false;
                        }
                        if ui.button("Discard").clicked() {
                            self.close();
                        }
                        if ui.add(apply_button(true)).clicked() {
                            if let (Some(mut draft), Some(project)) =
                                (self.draft.take(), project.as_mut())
                            {
                                self.apply(&mut draft, project);
                            }
                            self.close();
                        }
                    });
                });
            },
        );
    }

    /// How much of `room` each part of the left panel keeps. A window too
    /// small for all three shrinks them together — they scroll inside rather
    /// than push one another out of it.
    fn left_floors(room: f32) -> (f32, f32, f32) {
        let wanted = Self::TEST_SCREEN_MIN + Self::INSPECTOR_MIN + Self::LAYOUT_STACK_MIN;
        let share = (room / wanted).clamp(0.0, 1.0);
        (
            Self::TEST_SCREEN_MIN * share,
            Self::INSPECTOR_MIN * share,
            Self::LAYOUT_STACK_MIN * share,
        )
    }

    /// What a test tile would look like on the selected layout and mode, so the
    /// choice can be judged here rather than by patching a tile in the main
    /// window.
    fn show_test_screen(&mut self, ui: &mut Ui, patch: &Patch, collections: &Collections) {
        ui.label(
            egui::RichText::new("Test screen")
                .small()
                .color(Color32::from_gray(150)),
        );
        // It is square and has a size below which it says nothing, so a panel
        // too short for it scrolls rather than shrinking it away.
        let side = ui.available_size().min_elem().max(Self::TEST_SCREEN_MIN);
        ScrollArea::both()
            .id_salt("test screen")
            .auto_shrink([false; 2])
            .show(ui, |ui| {
                // as wide as there is room for, so the square sits in the middle
                let width = ui.available_width().max(side);
                let (rect, _) = ui.allocate_exact_size(Vec2::new(width, side), Sense::hover());
                test_screen::show(
                    ui,
                    rect,
                    patch,
                    LayoutSelection::new(self.state.selection, self.state.selected_mode),
                    collections,
                );
            });
    }
}