gled-ui 2.31.2

gled's egui interface, shared by the desktop app and the browser
Documentation
mod bpm_bar;

use super::App;
use crate::assets::AssetEdits;
use crate::ui::{logo::logo_image, window_common::window_buttons};
use bpm_bar::BpmBar;
use egui::{
    Button, Color32, Frame, Id, Image, Key, KeyboardShortcut, Modifiers, PointerButton, Sense,
    Slider, Stroke, TextFormat, Ui, UiKind, Vec2, ViewportCommand, ViewportId, text::LayoutJob,
};
use gled_model::asset::Asset;
use gled_protocol::action::UiAction;
use gled_protocol::state::FPS_LIMIT_MAX;
use std::sync::Arc;
use web_time::{SystemTime, UNIX_EPOCH};

const BPM_BAR_WIDTH: f32 = 500.0;
const BUTTON_HEIGHT: f32 = 25.0;

impl App {
    pub(crate) fn menu(&mut self, ui: &mut Ui, viewport_id: Option<ViewportId>) {
        egui::Panel::top(format!("{viewport_id:?} menu")).show(ui, |ui| {
            if self.loading().is_some() {
                ui.disable();
            }
            let title_bar_response = ui.interact(
                ui.available_rect_before_wrap(),
                Id::new("title_bar"),
                Sense::click_and_drag(),
            );

            // Interact with the title bar (drag to move window):
            if title_bar_response.double_clicked() {
                let is_maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
                ui.ctx()
                    .send_viewport_cmd(ViewportCommand::Maximized(!is_maximized));
            }

            if title_bar_response.drag_started_by(PointerButton::Primary) {
                ui.ctx().send_viewport_cmd(ViewportCommand::StartDrag);
            }

            egui::MenuBar::new().ui(ui, |ui| {
                Frame::new().show(ui, |ui| {
                    ui.set_height(32.0);
                    let mut bpm_rect = ui.available_rect_before_wrap();
                    bpm_rect.set_left((bpm_rect.width() - BPM_BAR_WIDTH) * 0.55);
                    bpm_rect.set_width(BPM_BAR_WIDTH);

                    self.menus(ui);
                    ui.put(bpm_rect, BpmBar { app: self });
                    self.window_buttons(ui);
                });
            });
        });
    }

    fn menus(&mut self, ui: &mut Ui) {
        ui.horizontal_centered(|ui| {
            ui.take_available_height();
            ui.scope(|ui| {
                ui.spacing_mut().button_padding = egui::vec2(4.0, 4.0);

                if ui.add(Button::image(Image::new(logo_image()))).clicked() {
                    self.windows.about.toggle();
                };

                ui.separator();

                let mut open_project = ui
                    .ctx()
                    .input_mut(|i| i.consume_key(Modifiers::COMMAND, Key::O));
                let mut save_project = ui
                    .ctx()
                    .input_mut(|i| i.consume_key(Modifiers::COMMAND, Key::S));

                ui.menu_button("Project", |ui| {
                    ui.set_min_width(300.0);

                    if let Some(project) = self
                        .project_id
                        .and_then(|id| Asset::get(id, &self.model.replica.assets))
                    {
                        ui.label(format!("Project: {}", project.name()));
                    } else {
                        ui.label("No project loaded");
                    }

                    ui.separator();

                    if ui
                        .add(Button::new("Load project").shortcut_text(
                            ui.ctx().format_shortcut(&KeyboardShortcut::new(
                                Modifiers::COMMAND,
                                Key::O,
                            )),
                        ))
                        .clicked()
                    {
                        open_project = true;
                        ui.close_kind(UiKind::Menu);
                    }

                    if ui
                        .add_enabled(
                            self.project.is_some(),
                            Button::new("Save project").shortcut_text(ui.ctx().format_shortcut(
                                &KeyboardShortcut::new(Modifiers::COMMAND, Key::S),
                            )),
                        )
                        .clicked()
                    {
                        save_project = true;
                        ui.close_kind(UiKind::Menu);
                    }

                    if ui
                        .add_enabled(self.project.is_some(), Button::new("Close project"))
                        .clicked()
                    {
                        // Sent to the engine with the next sync.
                        self.project.take();
                        ui.close_kind(UiKind::Menu);
                    }

                    ui.separator();

                    if ui
                        .add_enabled(self.project.is_some(), Button::new("Patch Fixtures"))
                        .clicked()
                    {
                        self.windows.fixture_window.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui
                        .add_enabled(self.project.is_some(), Button::new("External Devices"))
                        .clicked()
                    {
                        self.windows.external_device_settings.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui
                        .add_enabled(self.project.is_some(), Button::new("Channel Overwrites"))
                        .clicked()
                    {
                        self.windows.channel_overwrites.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui
                        .add_enabled(self.project.is_some(), Button::new("Output Routings"))
                        .clicked()
                    {
                        self.windows.output_routings.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui
                        .add_enabled(self.project.is_some(), Button::new("Shortcuts"))
                        .clicked()
                    {
                        self.windows.shortcuts.open();
                        ui.close_kind(UiKind::Menu);
                    }
                });

                if open_project {
                    self.windows.projects.open();
                }
                if save_project
                    && let (Some(project), Some(mut asset)) = (
                        self.project.as_ref(),
                        self.project_id
                            .and_then(|id| Asset::get(id, &self.model.replica.assets))
                            .map(Arc::unwrap_or_clone),
                    )
                {
                    asset.data = project.to_owned();
                    // The running Art-Net config also holds what arrived
                    // since the project was opened.
                    asset.data.artnet_config = self.model.replica.state.status.artnet.clone();
                    asset.data.output_routings = self.output_config.routings.clone();
                    asset.save(&mut self.model.replica.assets);
                }

                ui.menu_button("Assets", |ui| {
                    ui.set_min_width(300.0);

                    if ui.button("Animations").clicked() {
                        self.windows.animations.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui.button("Curves").clicked() {
                        self.windows.curves.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui.button("Fixture Definitions").clicked() {
                        self.windows.fixture_definitions.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui.button("Output Devices").clicked() {
                        self.windows.output_devices.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui.button("MIDI Controllers").clicked() {
                        self.windows.midi_controllers.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui.button("Palettes").clicked() {
                        self.windows.palettes.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui.button("Projects").clicked() {
                        self.windows.projects.open();
                        ui.close_kind(UiKind::Menu);
                    }
                    if ui.button("Scenes").clicked() {
                        self.windows.scenes.open();
                        ui.close_kind(UiKind::Menu);
                    }
                });

                // Open while its sliders are used: egui closes menus on any
                // click, including one on a slider inside them. Its buttons
                // close it themselves.
                let config_menu = egui::containers::menu::MenuConfig::new()
                    .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
                egui::containers::menu::MenuButton::new("Config")
                    .config(config_menu)
                    .ui(ui, |ui| {
                        ui.set_min_width(300.0);

                        if ui.button("Git Configuration").clicked() {
                            self.windows.git_config.open();
                            ui.close_kind(UiKind::Menu);
                        }
                        if let Some(extension) = &mut self.extension {
                            extension.config_menu(ui);
                        }

                        ui.separator();

                        ui.spacing_mut().slider_width = 290.0;
                        ui.label("Output framerate limit");
                        let mut fps_limit = self.server_settings().fps_limit.min(FPS_LIMIT_MAX);
                        if ui
                            .add(framerate_limit_slider(&mut fps_limit, 30.0, FPS_LIMIT_MAX))
                            .on_hover_text(
                                "Rate of the render and Art-Net/DMX output thread. \
                             ∞ renders as fast as the GPU allows. \
                             The window itself repaints at the monitor refresh rate.",
                            )
                            .changed()
                        {
                            self.change_server_settings(|settings| settings.fps_limit = fps_limit);
                        }

                        ui.separator();

                        egui::gui_zoom::zoom_menu_buttons(ui);
                    });

                let mut open_new_window = ui.ctx().input_mut(|i| {
                    i.consume_key(Modifiers::COMMAND.plus(Modifiers::SHIFT), Key::N)
                });
                ui.menu_button("Window", |ui| {
                    ui.set_min_width(300.0);

                    if ui
                        .add(Button::new("Open new window").shortcut_text(
                            ui.ctx().format_shortcut(&KeyboardShortcut::new(
                                Modifiers::COMMAND.plus(Modifiers::SHIFT),
                                Key::N,
                            )),
                        ))
                        .clicked()
                    {
                        open_new_window = true;
                        ui.close_kind(UiKind::Menu);
                    }
                });

                // A browser has one window per tab.
                #[cfg(target_arch = "wasm32")]
                if open_new_window {
                    crate::platform::open_in_new_tab();
                }
                #[cfg(not(target_arch = "wasm32"))]
                if open_new_window {
                    let viewport_id =
                        ViewportId(Id::new(format!("Second Window {}", uuid::Uuid::new_v4())));
                    self.other_main_windows.insert(viewport_id);
                }
                ui.separator();
            });
        });
    }

    fn window_buttons(&mut self, ui: &mut Ui) {
        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
            window_buttons(ui);

            let underlined = TextFormat {
                underline: Stroke::new(1.0, Color32::GRAY),
                ..Default::default()
            };
            let mut blackout_text = LayoutJob::default();
            blackout_text.append("B", 0.0, underlined);
            blackout_text.append("lackout", 0.0, TextFormat::default());
            let mut blackout = Button::new(blackout_text);
            if self.blackout
                && SystemTime::now()
                    .duration_since(UNIX_EPOCH)
                    .map(|d| d.as_millis() / 200 % 2 == 0)
                    .unwrap_or_default()
            {
                blackout = blackout.fill(Color32::DARK_RED);
            }
            // The blackout inputs are evaluated by the engine.
            if ui
                .add_sized(Vec2::new(100.0, BUTTON_HEIGHT), blackout)
                .clicked()
            {
                self.blackout = !self.blackout;
                UiAction::SetBlackout(self.blackout).enqueue();
            }

            ui.separator();
        });
    }
}

/// Slider for a frame rate limit whose maximum means unlimited and shows `∞`.
fn framerate_limit_slider(value: &mut f32, min: f32, max: f32) -> Slider<'_> {
    Slider::new(value, min..=max)
        .integer()
        .custom_formatter(move |n, _| {
            if n >= f64::from(max) {
                "∞".to_owned()
            } else {
                format!("{n:.0} fps")
            }
        })
}