gled-ui 2.31.2

gled's egui interface, shared by the desktop app and the browser
Documentation
//! The bar above the patch canvas: the background, adding fixtures or
//! choosing a layout's, the mode, undo and redo, and Apply.

use crate::canvas::definition_drawing::draw_definition_fitted;
use crate::canvas::view::fit_into;
use crate::fixture_editor::canvas::{self, ask_background};
use crate::fixture_editor::commands::EditorTransaction::{AddFixture, Redo, Undo};
use crate::fixture_editor::commands::process_transaction;
use crate::fixture_editor::draft::DraftStatus;
use crate::fixture_editor::state::{CanvasMode, PatchEdit};
use crate::fixture_editor::{FixtureWindow, NAME_CHAR_LIMIT, WARNING, apply_button};
use egui::emath::Align;
use egui::{Atoms, Color32, Frame, Id, IntoAtoms, Layout, Sense, TextEdit, Ui, UiBuilder, Vec2};
use egui_phosphor_icons::icons;
use gled_gpu::constants::UNIVERSE_SLOTS;
use gled_model::asset::Asset;
use gled_model::collection::Collections;
use gled_model::patching::definition::FixtureDefinition;
use gled_model::patching::fixture::LayoutPosition;
use gled_model::patching::layout::{Layout as PatchLayout, LayoutRef};

impl FixtureWindow {
    /// Returns whether the draft is to be handed to the show.
    pub(super) fn draw_editor(
        &mut self,
        ui: &mut Ui,
        edit: &mut PatchEdit<'_>,
        collections: &Collections,
        status: DraftStatus,
    ) -> bool {
        let mode = self.state.canvas_mode();
        let mut apply = false;
        ui.horizontal(|ui| {
            if ui
                .add_enabled(
                    !self.state.background_pick.is_open(),
                    egui::Button::new("Select Image"),
                )
                .clicked()
            {
                ask_background(ui.ctx(), edit.value, &mut self.state);
            }

            match self.state.selection {
                LayoutRef::Topology => self.add_fixture_button(ui, edit, collections),
                LayoutRef::Layout(_) => {
                    // A layout does not own fixtures, it picks from the ones
                    // the topology already has.
                    let choose: Atoms =
                        (icons::SELECTION_PLUS.fill(), "Choose Fixtures…").into_atoms();
                    if ui.button(choose).clicked() {
                        self.state.pick_members(true);
                    }
                }
            }

            self.mode_picker(ui, edit);

            ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
                apply = ui
                    .add_enabled(status.unapplied, apply_button(status.unapplied))
                    .on_hover_text("Hand the patch over to the running show")
                    .clicked();
                self.undo_buttons(ui, edit, collections, status);
                ui.label(match mode {
                    CanvasMode::Topology => PatchLayout::DEFAULT_NAME,
                    _ => "Layout",
                });
                let addressing = edit.value.addressing(collections);
                let dark = dark_universes(addressing.universes());
                if let (Some(first), Some(last)) = (dark.first(), dark.last()) {
                    ui.colored_label(WARNING, format!("âš  {} universes dark", dark.len()))
                        .on_hover_text(format!(
                            "gled renders up to {UNIVERSE_SLOTS} universes at once and this \
                             patch uses {}: universes {first} to {last} get no output.",
                            addressing.universes().len()
                        ));
                }
            });
        });

        canvas::show(ui, edit, &mut self.state, collections);
        apply
    }

    /// Undo and redo, right to left as they sit beside Apply.
    fn undo_buttons(
        &mut self,
        ui: &mut Ui,
        edit: &mut PatchEdit<'_>,
        collections: &Collections,
        status: DraftStatus,
    ) {
        let shortcut = |modifiers, key| {
            ui.ctx()
                .format_shortcut(&egui::KeyboardShortcut::new(modifiers, key))
        };
        let redo_hint = format!(
            "Redo ({})",
            shortcut(
                egui::Modifiers::COMMAND | egui::Modifiers::SHIFT,
                egui::Key::Z
            )
        );
        let undo_hint = format!(
            "Undo ({})",
            shortcut(egui::Modifiers::COMMAND, egui::Key::Z)
        );
        if ui
            .add_enabled(
                status.can_redo,
                egui::Button::new(icons::ARROW_CLOCKWISE.regular()),
            )
            .on_hover_text(redo_hint)
            .clicked()
        {
            process_transaction(edit, &mut self.state, collections, Redo);
        }
        if ui
            .add_enabled(
                status.can_undo,
                egui::Button::new(icons::ARROW_COUNTER_CLOCKWISE.regular()),
            )
            .on_hover_text(undo_hint)
            .clicked()
        {
            process_transaction(edit, &mut self.state, collections, Undo);
        }
    }

    /// Which of the selected layout's modes the editor edits. The stack is
    /// where modes are added and removed; switching between them belongs next
    /// to the canvas they change.
    fn mode_picker(&mut self, ui: &mut Ui, edit: &mut PatchEdit<'_>) {
        // Its own id space: the top bar's other button changes with the
        // selected layout, which would otherwise renumber these.
        ui.scope_builder(UiBuilder::new().id(Id::new("editor mode bar")), |ui| {
            ui.horizontal(|ui| self.mode_controls(ui, edit));
        });
    }

    fn mode_controls(&mut self, ui: &mut Ui, edit: &mut PatchEdit<'_>) {
        let layout_ref = self.state.selection;
        let Some(layout) = edit.value.layout(layout_ref) else {
            return;
        };
        let names: Vec<String> = layout.modes.iter().map(|mode| mode.name.clone()).collect();
        let selected = self.state.selected_mode.min(names.len().saturating_sub(1));
        let mut picked = None;
        let mut add = false;
        let mut remove = false;
        let mut renamed = names.get(selected).cloned().unwrap_or_default();
        let mut rename = None;

        ui.label("Mode");
        egui::ComboBox::from_id_salt("editor layout mode")
            .selected_text(names.get(selected).cloned().unwrap_or_default())
            .show_ui(ui, |ui| {
                for (index, name) in names.iter().enumerate() {
                    if ui.selectable_label(index == selected, name).clicked() {
                        picked = Some(index);
                    }
                }
            });
        if ui
            .button(icons::PLUS.regular())
            .on_hover_text("Add a mode to this layout")
            .clicked()
        {
            add = true;
        }
        // The default mode is what a layout falls back to: it has no name to
        // change and cannot be removed.
        if selected > 0 {
            if ui
                .button(icons::TRASH.regular().color(Color32::RED))
                .on_hover_text("Remove this mode")
                .clicked()
            {
                remove = true;
            }
            rename = Some(
                ui.add(
                    TextEdit::singleline(&mut renamed)
                        .char_limit(NAME_CHAR_LIMIT)
                        .desired_width(110.0)
                        .hint_text("Mode name"),
                ),
            );
        }

        if let Some(index) = picked {
            self.state.selected_mode = index;
        }
        if let Some(response) = rename {
            edit.tracked(&response, |patch| {
                if let Some(layout) = patch.layout_mut(layout_ref) {
                    layout.mode_mut(selected).name = renamed;
                }
            });
        }
        if add
            && let Some(added) =
                edit.apply(|patch| patch.layout_mut(layout_ref).map(|layout| layout.add_mode()))
        {
            self.state.selected_mode = added;
        }
        if remove && selected > 0 {
            edit.apply(|patch| {
                if let Some(layout) = patch.layout_mut(layout_ref) {
                    layout.remove_mode(selected);
                }
            });
            self.state.selected_mode = 0;
        }
    }

    /// Adding a fixture means choosing which one it is, so the button drops
    /// down the installed definitions. Each is shown as it will look on the
    /// canvas, and — where the definition carries one — as the lamp itself.
    fn add_fixture_button(
        &mut self,
        ui: &mut Ui,
        edit: &mut PatchEdit<'_>,
        collections: &Collections,
    ) {
        let mut definitions = Asset::<FixtureDefinition>::all(collections);
        let add: Atoms = (icons::LIGHTBULB.fill(), "Add Fixtures").into_atoms();

        if definitions.is_empty() {
            if ui
                .add(egui::Button::new(add))
                .on_hover_text("No fixture definitions yet: draw the first one")
                .clicked()
            {
                self.definitions_request = Some(None);
            }
            return;
        }

        definitions.sort_by(|a, b| a.path.cmp(&b.path));
        let mut chosen = None;
        egui::containers::menu::MenuButton::new(add).ui(ui, |ui| {
            // first, where a long list cannot push it out of reach
            if ui.button("Edit fixture definitions…").clicked() {
                self.definitions_request = Some(None);
                ui.close();
            }
            ui.separator();
            // scrolls, or the definitions past the window's edge are out of reach
            let height =
                (ui.ctx().content_rect().bottom() - ui.next_widget_position().y - 16.0).max(120.0);
            egui::ScrollArea::vertical()
                .max_height(height)
                .show(ui, |ui| {
                    for definition in definitions {
                        if self.definition_entry(ui, &definition).clicked() {
                            chosen = Some(definition.id);
                            ui.close();
                        }
                    }
                });
        });

        if let Some(definition) = chosen {
            // in the middle of what the editor is showing, so a new light does
            // not land off-screen
            let at = self.state.view.center;
            process_transaction(
                edit,
                &mut self.state,
                collections,
                AddFixture(LayoutPosition::at(at), Some(definition)),
            );
        }
    }

    /// One row of the add-light list.
    ///
    /// The columns have fixed widths, so every name starts at the same place
    /// whether or not its definition carries a photo.
    fn definition_entry(
        &mut self,
        ui: &mut Ui,
        definition: &Asset<FixtureDefinition>,
    ) -> egui::Response {
        const PREVIEW: Vec2 = Vec2::new(96.0, 54.0);
        const ROW_WIDTH: f32 = 340.0;
        let photo = definition
            .data
            .photo
            .as_ref()
            .and_then(|photo| self.state.caches.images.texture(ui.ctx(), &photo.file));
        let caches = &mut self.state.caches;

        ui.scope_builder(
            UiBuilder::new()
                .id(Id::new(("definition entry", definition.id.id)))
                .sense(Sense::click()),
            |ui| {
                let response = ui.response();
                Frame::new()
                    .fill(if response.hovered() {
                        Color32::from_gray(60)
                    } else {
                        Color32::TRANSPARENT
                    })
                    .inner_margin(4.0)
                    .show(ui, |ui| {
                        ui.set_width(ROW_WIDTH);
                        ui.horizontal(|ui| {
                            ui.set_height(PREVIEW.y);
                            let (outline, _) = ui.allocate_exact_size(PREVIEW, Sense::hover());
                            draw_definition_fitted(
                                &ui.painter_at(outline),
                                outline.shrink(3.0),
                                &definition.data,
                                Some(definition.id.id),
                                caches,
                            );
                            // the slot is kept even where there is no photo
                            let (slot, _) = ui.allocate_exact_size(PREVIEW, Sense::hover());
                            if let Some(photo) = &photo {
                                // fitted, not stretched: a drawing keeps its shape
                                egui::Image::new(photo)
                                    .corner_radius(3.0)
                                    .paint_at(ui, fit_into(photo.size_vec2(), slot));
                            }
                            // Selectable text swallows the press, so parts of
                            // the row would not act as the button it is.
                            ui.vertical(|ui| {
                                ui.add(
                                    egui::Label::new(
                                        egui::RichText::new(definition.name()).strong(),
                                    )
                                    .selectable(false),
                                );
                                ui.add(
                                    egui::Label::new(
                                        egui::RichText::new(format!(
                                            "{} px · {} ch",
                                            definition.data.pixels.len(),
                                            definition.data.channel_count()
                                        ))
                                        .small()
                                        .color(Color32::from_gray(160)),
                                    )
                                    .selectable(false),
                                );
                            });
                        });
                    });
            },
        )
        .response
    }
}

/// The universes a patch uses past the ones gled renders at once: the output
/// has no slot for them, so they stay dark.
fn dark_universes(universes: &[u16]) -> &[u16] {
    universes.get(UNIVERSE_SLOTS as usize..).unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn only_universes_past_the_slots_stay_dark() {
        let slots = UNIVERSE_SLOTS as usize;
        let universes: Vec<u16> = (0..slots as u16 + 44).map(|i| i * 2).collect();
        assert!(dark_universes(&universes[..slots]).is_empty());
        assert_eq!(dark_universes(&universes), &universes[slots..]);
        assert!(dark_universes(&[]).is_empty());
    }
}