gled-ui 2.31.0

gled's egui interface, shared by the desktop app and the browser
Documentation
pub(crate) mod client_timing;
pub(crate) mod config;
mod connection_banner;
pub(crate) mod effects;
mod engine_sync;
pub(crate) mod menu;
pub(crate) mod no_project;
pub(crate) mod preview;
mod render;
pub(crate) mod status_bar;
pub(crate) mod storage;

/// Beat timing, from the model.
pub(crate) mod timing {
    pub(crate) use gled_model::timing::BeatState;
}

use crate::canvas::images::Images;
use crate::ui::frame_requests::FrameRequests;
use crate::{
    extension::AppExtension, link::Connection, model::ClientModel, settings::ClientSettings,
};
use crate::{
    pipeline::{
        renderer_callback::RendererCallback,
        thumbnails::{ThumbnailTextures, UiPreview},
    },
    ui::{
        asset_tree::AssetTree, local_action::LocalAction,
        scene_effect_editor::SceneEffectEditorState, window_common::default_viewport_builder,
        windows::Windows,
    },
};
use client_timing::ClientTiming;
use eframe::egui_wgpu::Callback;
use egui::{CentralPanel, Id, LayerId, Rect, Ui, UiBuilder, ViewportId};
use engine_sync::EngineSync;
use gled_gpu::effect_state::EffectPipelines;
use gled_model::{
    asset::{Asset, palette::Palette, project::Project, scene::grid::GridLocation},
    asset_id::AssetId,
};
use gled_protocol::action::UiAction;
use gled_protocol::state::ClientInput;
use gled_protocol::state::OutputConfig;
use kanal::{Receiver, Sender};
use std::collections::HashSet;
use storage::{show_storage_error, show_storage_loading};

#[derive(Debug)]
pub struct App {
    pub(crate) windows: Windows,
    pub(crate) timing: ClientTiming,
    /// The link to the engine.
    pub(crate) connection: Connection,
    /// What the host adds, such as remote access on the desktop.
    extension: Option<Box<dyn AppExtension>>,
    sync: EngineSync,
    pub(crate) project: Option<Project>,
    pub(crate) project_id: Option<AssetId<Project>>,
    pub(crate) other_main_windows: HashSet<ViewportId>,
    pub(crate) blackout: bool,
    pub(crate) blackout_hold: bool,
    pub(crate) selected_scene_instance: GridLocation,
    pub(crate) selected_scene_effect_editor: SceneEffectEditorState,
    pub(crate) git_commit_message: String,
    pub(crate) midi_monitor_receiver: kanal::Receiver<gled_protocol::midi::MidiMonitorEvent>,
    midi_monitor_sender: Sender<gled_protocol::midi::MidiMonitorEvent>,
    pub(crate) test_command_sender: Sender<gled_protocol::midi::TestCommand>,
    test_command_receiver: Receiver<gled_protocol::midi::TestCommand>,
    pub(crate) midi_learn_state: crate::midi_learn::LearnState,
    pub(crate) last_title: String,
    pub(crate) midi_output_active: bool,
    pub(crate) palette_asset_tree: AssetTree<Palette>,
    pub(crate) palette_asset_tree_id: Option<Id>,
    pub(crate) network_stats: (f64, f64),
    /// This interface's own settings, stored by eframe.
    pub(crate) settings: ClientSettings,
    pub(crate) output_config: OutputConfig,
    /// What the engine published, as a remote interface would see it.
    pub(crate) model: ClientModel,
    /// What the windows asked of the engine during the frame.
    frame_requests: FrameRequests,
    /// Whether the preview currently shows output that must be cleared when
    /// the project is closed.
    pub(crate) preview_has_output: bool,
    /// Thumbnail pipelines of the project's effects, on the UI device.
    pub(crate) thumbnails: EffectPipelines,
    /// egui textures of those thumbnails.
    pub(crate) thumbnail_textures: ThumbnailTextures,
    /// The LED preview on the UI device.
    pub(crate) ui_preview: UiPreview,
    /// The rig layout's background, drawn behind the LED preview.
    pub(crate) preview_background: Images,
}

impl eframe::App for App {
    fn save(&mut self, storage: &mut dyn eframe::Storage) {
        eframe::set_value(storage, crate::settings::STORAGE_KEY, &self.settings);
    }

    #[cfg_attr(feature = "profiling", profiling::function)]
    fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
        self.send_local_changes();
        self.receive_engine_messages();
        crate::ui::input::set_frame_input_events(std::mem::take(&mut self.model.input_events));
        for event in std::mem::take(&mut self.model.midi_monitor) {
            let _ = self.midi_monitor_sender.send(event);
        }
        for message in std::mem::take(&mut self.model.midi_learn) {
            self.midi_learn_state.feed(message);
        }
        self.receive_engine_state();
        self.handle_engine_events();
        self.handle_local_actions();
        self.send_keys(ctx);

        let title = format!(
            "gled - {}",
            match self
                .project_id
                .and_then(|id| Asset::get(id, &self.model.replica.assets))
            {
                None => "No project".to_string(),
                Some(project) => project.name().to_string(),
            }
        );
        if title != self.last_title {
            ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
            // The browser's backend ignores the command: the tab shows the
            // page's title.
            #[cfg(target_arch = "wasm32")]
            crate::platform::set_page_title(&title);
            self.last_title = title;
        }

        self.render_thumbnails_and_preview();
    }

    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
        let ctx = ui.ctx().clone();

        self.draw_main_window(&ctx, None);

        let viewport_ids = self.other_main_windows.clone();
        for viewport_id in viewport_ids {
            ctx.show_viewport_immediate(
                viewport_id,
                default_viewport_builder()
                    .with_inner_size([1300.0, 1024.0])
                    .with_drag_and_drop(true)
                    .with_min_inner_size([300.0, 200.0]),
                |ui, _viewport_class| {
                    ui.ctx().input(|input| {
                        if input.viewport().close_requested() {
                            LocalAction::CloseWindow(viewport_id).enqueue();
                        }
                    });

                    self.draw_main_window(ui.ctx(), Some(viewport_id));
                },
            );
        }

        if let Some(extension) = &mut self.extension {
            extension.update(&ctx);
        }
        self.frame_requests
            .begin(&self.model.replica.state.status.devices);
        self.windows.update(
            &ctx,
            &self.timing,
            &mut self.project,
            &mut self.model.replica.assets,
            &mut self.settings,
            &mut self.output_config,
            &self.model.sound,
            &self.midi_monitor_receiver,
            &self.test_command_sender,
            &mut self.midi_learn_state,
            &self.model.replica.state.status,
        );
        while let Ok(Some(command)) = self.test_command_receiver.try_recv() {
            ClientInput::MidiTest(command).enqueue();
        }
        self.frame_requests
            .end(self.midi_learn_state.active_request().is_some());

        // The thumbnails move on every frame, so the window repaints
        // continuously, at the monitor's refresh rate.
        ctx.request_repaint();
    }
}

impl App {
    /// Selects a scene instance here right away and in the engine.
    pub(crate) fn set_selected_scene_instance(&mut self, location: GridLocation) {
        if self.selected_scene_instance != location {
            self.selected_scene_instance = location;
            self.selected_scene_effect_editor.reset();
        }
        UiAction::SelectSceneByLocation(location).enqueue();
    }

    /// What the interface waits for before it can show the show: the engine's
    /// first state, then the asset repository.
    pub(crate) fn loading(&self) -> Option<String> {
        if !self.model.replica.complete {
            return Some(match self.connection.target() {
                // Its own line; a wrapped one would be stretched.
                Some(target) => format!("Connecting to the engine\nat {target}"),
                None => "Connecting to the engine".to_owned(),
            });
        }
        self.model.replica.state.status.storage_task.clone()
    }

    fn handle_local_actions(&mut self) {
        for action in LocalAction::take_all() {
            match action {
                LocalAction::CloseWindow(viewport_id) => {
                    self.other_main_windows.remove(&viewport_id);
                }
                LocalAction::OpenGitConfigWindow => self.windows.git_config.open(),
            }
        }
    }

    #[cfg_attr(feature = "profiling", profiling::function)]
    pub(crate) fn draw_main_window(
        &mut self,
        ctx: &egui::Context,
        viewport_id: Option<ViewportId>,
    ) {
        let panel_frame = egui::Frame::new()
            .fill(ctx.global_style().visuals.window_fill())
            .stroke(ctx.global_style().visuals.widgets.noninteractive.fg_stroke);

        let mut root_ui = Ui::new(
            ctx.clone(),
            Id::new((ctx.viewport_id(), "main_window_panel")),
            UiBuilder::new()
                .layer_id(LayerId::background())
                .max_rect(ctx.content_rect()),
        );
        root_ui.set_clip_rect(ctx.content_rect());

        CentralPanel::default()
            .frame(panel_frame)
            .show(&mut root_ui, |ui| {
                if viewport_id.is_none() {
                    let callback = Callback::new_paint_callback(Rect::ZERO, RendererCallback);
                    ui.painter().add(callback);
                }
                let mut ui = ui.new_child(UiBuilder::new().max_rect(ui.max_rect().shrink(4.0)));

                self.menu(&mut ui, viewport_id);
                self.connection_banner(&mut ui, viewport_id);
                self.status_bar(&mut ui, viewport_id);

                let status = &self.model.replica.state.status;
                if let Some(error) = &status.storage_error {
                    show_storage_error(&mut ui, error, &status.storage_dir);
                    return;
                } else if let Some(task) = self.loading() {
                    show_storage_loading(&mut ui, &task, status.storage_progress);
                    return;
                }

                if self.project.is_some() {
                    egui::Panel::left("config")
                        .resizable(false)
                        .exact_size(400.0)
                        .show(&mut ui, |ui| self.config(ui));
                    egui::Panel::top("preview")
                        .resizable(true)
                        .default_size(200.0)
                        .min_size(200.0)
                        .show(&mut ui, |ui| self.preview(ui));
                    egui::CentralPanel::default().show(&mut ui, |ui| self.scenes(ui));
                } else {
                    self.no_project(&mut ui);
                }
            });
    }
    /// The interface on `connection`, with the host's `extension`.
    pub fn new(
        connection: Connection,
        settings: ClientSettings,
        extension: Option<Box<dyn AppExtension>>,
    ) -> Self {
        // The MIDI window reads the monitor and writes test commands through
        // channels; the app fills and drains them from the engine's messages.
        let (midi_monitor_sender, midi_monitor_receiver) = kanal::unbounded();
        let (test_command_sender, test_command_receiver) = kanal::unbounded();
        Self {
            timing: ClientTiming::default(),
            connection,
            extension,
            sync: EngineSync::default(),
            blackout: true,
            blackout_hold: false,
            selected_scene_instance: Default::default(),
            selected_scene_effect_editor: Default::default(),
            project: Default::default(),
            project_id: Default::default(),
            windows: Default::default(),
            other_main_windows: Default::default(),
            git_commit_message: Default::default(),
            midi_monitor_receiver,
            midi_monitor_sender,
            test_command_sender,
            test_command_receiver,
            midi_learn_state: crate::midi_learn::LearnState::default(),
            last_title: Default::default(),
            midi_output_active: false,
            palette_asset_tree: AssetTree {
                only_asset_selection: true,
                ..Default::default()
            },
            palette_asset_tree_id: None,
            network_stats: Default::default(),
            settings,
            output_config: OutputConfig::default(),
            model: ClientModel::default(),
            frame_requests: FrameRequests::default(),
            preview_has_output: false,
            thumbnails: EffectPipelines::default(),
            thumbnail_textures: ThumbnailTextures::default(),
            ui_preview: UiPreview::new(&crate::wgpu_render_state()),
            preview_background: Images::default(),
        }
    }
}