Skip to main content

gled_ui/app/
mod.rs

1pub(crate) mod client_timing;
2pub(crate) mod config;
3pub(crate) mod effects;
4pub(crate) mod menu;
5pub(crate) mod no_project;
6pub(crate) mod preview;
7pub(crate) mod project_functions;
8pub(crate) mod status_bar;
9pub(crate) mod storage;
10pub(crate) mod svg;
11
12/// Beat timing, from the model.
13pub(crate) mod timing {
14    pub(crate) use gled_model::timing::BeatState;
15}
16
17use crate::ui::frame_requests::FrameRequests;
18use crate::{
19    extension::AppExtension, link::Connection, model::ClientModel, settings::ClientSettings,
20};
21use crate::{
22    key_code::key_code,
23    pipeline::{
24        renderer_callback::RendererCallback,
25        thumbnails::{ThumbnailTextures, UiPreview},
26        ui_gpu,
27    },
28    ui::{
29        asset_tree::AssetTree, local_action::LocalAction, project_edits::project_edits,
30        scene_effect_editor::SceneEffectEditorState, window_common::default_viewport_builder,
31        windows::Windows,
32    },
33};
34use client_timing::ClientTiming;
35use eframe::egui_wgpu::Callback;
36use egui::{CentralPanel, Id, LayerId, Rect, Ui, UiBuilder, ViewportId};
37use gled_gpu::{
38    effect_state::EffectPipelines,
39    scene_render::{EvalContext, ensure_project_matrix_pipelines, render_matrices},
40};
41use gled_model::{
42    asset::{
43        Asset, output_device::routing::OutputRoutings, palette::Palette, project::Project,
44        scene::grid::GridLocation,
45    },
46    asset_id::AssetId,
47    input::key_code::KeyCode,
48};
49use gled_protocol::action::UiAction;
50use gled_protocol::state::OutputConfig;
51use gled_protocol::state::{ClientInput, EngineEvent, ServerSettings};
52use kanal::{Receiver, Sender};
53use std::collections::{BTreeSet, HashSet};
54use std::sync::Arc;
55use storage::{show_storage_error, show_storage_loading};
56use wgpu::CommandEncoderDescriptor;
57
58/// What the interface last exchanged with the engine, to tell its own edits
59/// from the engine's state.
60#[derive(Debug, Default)]
61struct EngineSync {
62    /// The project as last adopted, plus the edits sent since.
63    project: Option<Project>,
64    /// The output routings as last sent or adopted.
65    routings: Arc<OutputRoutings>,
66    /// The held keys as last sent.
67    keys: Option<BTreeSet<KeyCode>>,
68    /// The engine's lamp layout the preview was last built for.
69    layout_generation: u64,
70}
71
72#[derive(Debug)]
73pub struct App {
74    pub(crate) windows: Windows,
75    pub(crate) timing: ClientTiming,
76    /// The link to the engine.
77    pub(crate) connection: Connection,
78    /// What the host adds, such as remote access on the desktop.
79    extension: Option<Box<dyn AppExtension>>,
80    sync: EngineSync,
81    pub(crate) project: Option<Project>,
82    pub(crate) project_id: Option<AssetId<Project>>,
83    pub(crate) other_main_windows: HashSet<ViewportId>,
84    pub(crate) blackout: bool,
85    pub(crate) blackout_hold: bool,
86    pub(crate) selected_scene_instance: GridLocation,
87    pub(crate) selected_scene_effect_editor: SceneEffectEditorState,
88    pub(crate) git_commit_message: String,
89    pub(crate) midi_monitor_receiver: kanal::Receiver<gled_protocol::midi::MidiMonitorEvent>,
90    midi_monitor_sender: Sender<gled_protocol::midi::MidiMonitorEvent>,
91    pub(crate) test_command_sender: Sender<gled_protocol::midi::TestCommand>,
92    test_command_receiver: Receiver<gled_protocol::midi::TestCommand>,
93    pub(crate) midi_learn_state: crate::midi_learn::LearnState,
94    pub(crate) last_title: String,
95    pub(crate) midi_output_active: bool,
96    pub(crate) palette_asset_tree: AssetTree<Palette>,
97    pub(crate) palette_asset_tree_id: Option<Id>,
98    pub(crate) network_stats: (f64, f64),
99    /// This interface's own settings, stored by eframe.
100    pub(crate) settings: ClientSettings,
101    pub(crate) output_config: OutputConfig,
102    /// What the engine published, as a remote interface would see it.
103    pub(crate) model: ClientModel,
104    /// What the windows asked of the engine during the frame.
105    frame_requests: FrameRequests,
106    /// Whether the preview currently shows output that must be cleared when
107    /// the project is closed.
108    pub(crate) preview_has_output: bool,
109    pub(crate) svg_runtime: svg::SvgRuntime,
110    /// Thumbnail pipelines of the project's effects, on the UI device.
111    pub(crate) thumbnails: EffectPipelines,
112    /// egui textures of those thumbnails.
113    pub(crate) thumbnail_textures: ThumbnailTextures,
114    /// The LED preview on the UI device.
115    pub(crate) ui_preview: UiPreview,
116}
117
118impl eframe::App for App {
119    fn save(&mut self, storage: &mut dyn eframe::Storage) {
120        eframe::set_value(storage, crate::settings::STORAGE_KEY, &self.settings);
121    }
122
123    #[cfg_attr(feature = "profiling", profiling::function)]
124    fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
125        self.send_local_changes();
126        self.receive_engine_messages();
127        crate::ui::input::set_frame_input_events(std::mem::take(&mut self.model.input_events));
128        for event in std::mem::take(&mut self.model.midi_monitor) {
129            let _ = self.midi_monitor_sender.send(event);
130        }
131        for message in std::mem::take(&mut self.model.midi_learn) {
132            self.midi_learn_state.feed(message);
133        }
134        self.receive_engine_state();
135        self.handle_engine_events();
136        self.handle_local_actions();
137        self.send_keys(ctx);
138
139        let title = format!(
140            "gled - {}",
141            match self
142                .project_id
143                .and_then(|id| Asset::get(id, &self.model.replica.assets))
144            {
145                None => "No project".to_string(),
146                Some(project) => project.name().to_string(),
147            }
148        );
149        if title != self.last_title {
150            ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
151            // The browser's backend ignores the command: the tab shows the
152            // page's title.
153            #[cfg(target_arch = "wasm32")]
154            crate::platform::set_page_title(&title);
155            self.last_title = title;
156        }
157
158        self.render_thumbnails_and_preview();
159    }
160
161    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
162        let ctx = ui.ctx().clone();
163
164        self.draw_main_window(&ctx, None);
165
166        let viewport_ids = self.other_main_windows.clone();
167        for viewport_id in viewport_ids {
168            ctx.show_viewport_immediate(
169                viewport_id,
170                default_viewport_builder()
171                    .with_inner_size([1300.0, 1024.0])
172                    .with_drag_and_drop(true)
173                    .with_min_inner_size([300.0, 200.0]),
174                |ui, _viewport_class| {
175                    ui.ctx().input(|input| {
176                        if input.viewport().close_requested() {
177                            LocalAction::CloseWindow(viewport_id).enqueue();
178                        }
179                    });
180
181                    self.draw_main_window(ui.ctx(), Some(viewport_id));
182                },
183            );
184        }
185
186        if let Some(extension) = &mut self.extension {
187            extension.update(&ctx);
188        }
189        self.frame_requests
190            .begin(&self.model.replica.state.status.devices, &self.model.layout);
191        self.windows.update(
192            &ctx,
193            &self.timing,
194            &mut self.project,
195            &mut self.model.replica.assets,
196            &mut self.settings,
197            &mut self.output_config,
198            &self.model.sound,
199            &self.midi_monitor_receiver,
200            &self.test_command_sender,
201            &mut self.midi_learn_state,
202            &self.model.replica.state.status,
203            &self.model.layout,
204        );
205        while let Ok(Some(command)) = self.test_command_receiver.try_recv() {
206            ClientInput::MidiTest(command).enqueue();
207        }
208        self.frame_requests
209            .end(self.midi_learn_state.active_request().is_some());
210
211        // The thumbnails move on every frame, so the window repaints
212        // continuously, at the monitor's refresh rate.
213        ctx.request_repaint();
214    }
215}
216
217impl App {
218    /// Selects a scene instance here right away and in the engine.
219    pub(crate) fn set_selected_scene_instance(&mut self, location: GridLocation) {
220        if self.selected_scene_instance != location {
221            self.selected_scene_instance = location;
222            self.selected_scene_effect_editor.reset();
223        }
224        UiAction::SelectSceneByLocation(location).enqueue();
225    }
226
227    /// Sends what was edited directly since the last frame, as actions.
228    fn send_local_changes(&mut self) {
229        for action in project_edits(&self.sync.project, &self.project) {
230            action.enqueue();
231        }
232        self.sync.project = self.project.clone();
233        if self.output_config.routings != self.sync.routings {
234            self.sync.routings = self.output_config.routings.clone();
235            UiAction::SetOutputRoutings(self.output_config.routings.clone()).enqueue();
236        }
237    }
238
239    /// Feeds what the engine published into the client model, as the same
240    /// messages a remote interface gets.
241    fn receive_engine_messages(&mut self) {
242        for message in self.connection.receive() {
243            self.model.apply(message);
244        }
245        if std::mem::take(&mut self.model.welcomed) {
246            self.connection.reset_sequence();
247        }
248    }
249
250    /// Takes the engine's newest state from the model. The project and the
251    /// routings are adopted only once the engine has applied everything sent,
252    /// so edits do not jump back for a frame.
253    fn receive_engine_state(&mut self) {
254        if !std::mem::take(&mut self.model.state_changed) || !self.model.replica.complete {
255            return;
256        }
257        let replica = std::mem::take(&mut self.model.replica);
258        let state = &replica.state;
259
260        if state.project_id != self.project_id {
261            self.svg_runtime.reset();
262            self.selected_scene_effect_editor.reset();
263        }
264        self.project_id = state.project_id;
265        if state.selected_scene_instance != self.selected_scene_instance {
266            self.selected_scene_instance = state.selected_scene_instance;
267            self.selected_scene_effect_editor.reset();
268        }
269        self.network_stats = (
270            f64::from(state.status.network_in),
271            f64::from(state.status.network_out),
272        );
273        self.blackout = state.blackout;
274        self.blackout_hold = state.blackout_hold;
275        self.midi_output_active = state.midi_output_active;
276        self.timing.update(state.beat, state.tap_count);
277        self.output_config.universes = state.output_config.universes.clone();
278
279        if replica.applied_sequence >= self.connection.sent_sequence()
280            && (state.project != self.sync.project
281                || state.output_config.routings != self.sync.routings)
282        {
283            if state.project.is_none() && self.project.is_some() {
284                self.windows.external_device_settings.close();
285                self.windows.output_routings.close();
286                self.windows.shortcuts.close();
287            }
288            self.project = state.project.clone();
289            self.sync.project = state.project.clone();
290            self.output_config.routings = state.output_config.routings.clone();
291            self.sync.routings = state.output_config.routings.clone();
292        }
293        self.model.replica = replica;
294
295        if self.model.layout.generation() != self.sync.layout_generation {
296            self.sync.layout_generation = self.model.layout.generation();
297            self.ui_preview.indices.send_positions();
298        }
299    }
300
301    /// The settings of the machine running the engine.
302    pub(crate) fn server_settings(&self) -> &ServerSettings {
303        &self.model.replica.state.status.settings
304    }
305
306    /// Sends the server settings with `change` applied.
307    pub(crate) fn change_server_settings(&self, change: impl FnOnce(&mut ServerSettings)) {
308        let mut settings = self.server_settings().clone();
309        change(&mut settings);
310        ClientInput::SetServerSettings(settings).enqueue();
311    }
312
313    /// A red bar while the connection to a remote engine is interrupted, with
314    /// the time since the last contact.
315    fn connection_banner(&self, ui: &mut egui::Ui, viewport_id: Option<ViewportId>) {
316        if !self.connection.is_remote() {
317            return;
318        }
319        // Silence is only noticed when the interface looks again.
320        ui.ctx()
321            .request_repaint_after(web_time::Duration::from_secs(1));
322        let Some(interruption) = self.connection.interruption() else {
323            return;
324        };
325        egui::Panel::top(format!("{viewport_id:?} connection"))
326            .frame(
327                egui::Frame::new()
328                    .fill(egui::Color32::DARK_RED)
329                    .inner_margin(egui::Margin::same(4)),
330            )
331            .show(ui, |ui| {
332                ui.vertical_centered(|ui| {
333                    ui.colored_label(
334                        egui::Color32::WHITE,
335                        format!(
336                            "Connection to the engine interrupted, last contact {} ago. \
337                             Reconnecting…",
338                            elapsed(interruption.last_contact)
339                        ),
340                    );
341                });
342            });
343    }
344
345    /// What the interface waits for before it can show the show: the engine's
346    /// first state, then the asset repository.
347    pub(crate) fn loading(&self) -> Option<String> {
348        if !self.model.replica.complete {
349            return Some(match self.connection.target() {
350                // Its own line; a wrapped one would be stretched.
351                Some(target) => format!("Connecting to the engine\nat {target}"),
352                None => "Connecting to the engine".to_owned(),
353            });
354        }
355        self.model.replica.state.status.storage_task.clone()
356    }
357
358    fn handle_local_actions(&mut self) {
359        for action in LocalAction::take_all() {
360            match action {
361                LocalAction::CloseWindow(viewport_id) => {
362                    self.other_main_windows.remove(&viewport_id);
363                }
364                LocalAction::OpenGitConfigWindow => self.windows.git_config.open(),
365            }
366        }
367    }
368
369    fn handle_engine_events(&mut self) {
370        for event in std::mem::take(&mut self.model.events) {
371            match event {
372                EngineEvent::ShaderReloaded(animation) => {
373                    self.thumbnails.invalidate(animation);
374                    self.windows.scenes.invalidate_pipelines(animation);
375                }
376                EngineEvent::Error(error) => {
377                    #[cfg(not(target_arch = "wasm32"))]
378                    if let Err(err) = notify_rust::Notification::new()
379                        .summary("Error")
380                        .body(&error)
381                        .icon("application-gled")
382                        .show()
383                    {
384                        tracing::error!("Could not show notification: {err:?}");
385                    }
386                    self.windows.errors.entries.push(error);
387                }
388            }
389        }
390    }
391
392    /// Sends the held keys when they changed. While a text field has the
393    /// keyboard, the keys are not sent, as before: typing triggers nothing.
394    fn send_keys(&mut self, ctx: &egui::Context) {
395        if ctx.egui_wants_keyboard_input() {
396            return;
397        }
398        let keys: BTreeSet<KeyCode> =
399            ctx.input(|input| input.keys_down.iter().copied().map(key_code).collect());
400        if self.sync.keys.as_ref() != Some(&keys) {
401            self.sync.keys = Some(keys.clone());
402            ClientInput::Keys(keys).enqueue();
403        }
404    }
405
406    /// Render the effect thumbnails and the LED
407    /// preview on the UI device. The commands are submitted by egui's paint
408    /// through `RendererCallback`, so they never race the window surface.
409    #[cfg_attr(feature = "profiling", profiling::function)]
410    fn render_thumbnails_and_preview(&mut self) {
411        let gpu = ui_gpu();
412        let mut encoder = gpu
413            .device
414            .create_command_encoder(&CommandEncoderDescriptor {
415                label: Some("Render thumbnails and preview"),
416            });
417
418        if let Some(project) = &self.project {
419            ensure_project_matrix_pipelines(
420                project,
421                &mut self.thumbnails,
422                &gpu,
423                &self.model.replica.assets,
424            );
425            render_matrices(
426                project,
427                &mut self.thumbnails,
428                &gpu,
429                &mut encoder,
430                &self.timing.beat_state(),
431                EvalContext {
432                    layout: &self.model.layout,
433                    collections: &self.model.replica.assets,
434                    sound: &*self.model.sound,
435                },
436            );
437            self.thumbnail_textures.sync(&self.thumbnails);
438        }
439
440        let latest_output = self.model.preview.take();
441        match (self.project.is_some(), latest_output) {
442            (true, Some(output)) => {
443                self.ui_preview.preview.upload_output(&gpu.queue, &output);
444                self.preview_has_output = true;
445            }
446            (false, _) if self.preview_has_output => {
447                self.ui_preview.preview.clear_output(&gpu.queue);
448                self.preview_has_output = false;
449            }
450            _ => {}
451        }
452
453        self.ui_preview
454            .indices
455            .prepare(&gpu.queue, &self.model.layout);
456        self.ui_preview.indices.run(&mut encoder);
457        self.ui_preview.preview.run(&mut encoder);
458        RendererCallback::try_add(encoder.finish());
459    }
460
461    #[cfg_attr(feature = "profiling", profiling::function)]
462    pub(crate) fn draw_main_window(
463        &mut self,
464        ctx: &egui::Context,
465        viewport_id: Option<ViewportId>,
466    ) {
467        let panel_frame = egui::Frame::new()
468            .fill(ctx.global_style().visuals.window_fill())
469            .stroke(ctx.global_style().visuals.widgets.noninteractive.fg_stroke);
470
471        let mut root_ui = Ui::new(
472            ctx.clone(),
473            Id::new((ctx.viewport_id(), "main_window_panel")),
474            UiBuilder::new()
475                .layer_id(LayerId::background())
476                .max_rect(ctx.content_rect()),
477        );
478        root_ui.set_clip_rect(ctx.content_rect());
479
480        CentralPanel::default()
481            .frame(panel_frame)
482            .show(&mut root_ui, |ui| {
483                if viewport_id.is_none() {
484                    let callback = Callback::new_paint_callback(Rect::ZERO, RendererCallback);
485                    ui.painter().add(callback);
486                }
487                let mut ui = ui.new_child(UiBuilder::new().max_rect(ui.max_rect().shrink(4.0)));
488
489                self.menu(&mut ui, viewport_id);
490                self.connection_banner(&mut ui, viewport_id);
491                self.status_bar(&mut ui, viewport_id);
492
493                let status = &self.model.replica.state.status;
494                if let Some(error) = &status.storage_error {
495                    show_storage_error(&mut ui, error, &status.storage_dir);
496                    return;
497                } else if let Some(task) = self.loading() {
498                    show_storage_loading(&mut ui, &task);
499                    return;
500                }
501
502                if self.project.is_some() {
503                    egui::Panel::left("config")
504                        .resizable(false)
505                        .exact_size(400.0)
506                        .show(&mut ui, |ui| self.config(ui));
507                    egui::Panel::top("preview")
508                        .resizable(true)
509                        .default_size(200.0)
510                        .min_size(200.0)
511                        .show(&mut ui, |ui| self.preview(ui));
512                    egui::CentralPanel::default().show(&mut ui, |ui| self.scenes(ui));
513                } else {
514                    self.no_project(&mut ui);
515                }
516            });
517    }
518    /// The interface on `connection`, with the host's `extension`.
519    pub fn new(
520        connection: Connection,
521        settings: ClientSettings,
522        extension: Option<Box<dyn AppExtension>>,
523    ) -> Self {
524        // The MIDI window reads the monitor and writes test commands through
525        // channels; the app fills and drains them from the engine's messages.
526        let (midi_monitor_sender, midi_monitor_receiver) = kanal::unbounded();
527        let (test_command_sender, test_command_receiver) = kanal::unbounded();
528        Self {
529            timing: ClientTiming::default(),
530            connection,
531            extension,
532            sync: EngineSync::default(),
533            blackout: true,
534            blackout_hold: false,
535            selected_scene_instance: Default::default(),
536            selected_scene_effect_editor: Default::default(),
537            project: Default::default(),
538            project_id: Default::default(),
539            windows: Default::default(),
540            other_main_windows: Default::default(),
541            git_commit_message: Default::default(),
542            midi_monitor_receiver,
543            midi_monitor_sender,
544            test_command_sender,
545            test_command_receiver,
546            midi_learn_state: crate::midi_learn::LearnState::default(),
547            last_title: Default::default(),
548            midi_output_active: false,
549            palette_asset_tree: AssetTree {
550                only_asset_selection: true,
551                ..Default::default()
552            },
553            palette_asset_tree_id: None,
554            network_stats: Default::default(),
555            settings,
556            output_config: OutputConfig::default(),
557            model: ClientModel::default(),
558            frame_requests: FrameRequests::default(),
559            preview_has_output: false,
560            svg_runtime: Default::default(),
561            thumbnails: EffectPipelines::default(),
562            thumbnail_textures: ThumbnailTextures::default(),
563            ui_preview: UiPreview::new(&crate::wgpu_render_state()),
564        }
565    }
566}
567
568/// A duration as people say it: `45s`, `6m20s`, `2h5m`.
569fn elapsed(duration: web_time::Duration) -> String {
570    let seconds = duration.as_secs();
571    match seconds {
572        0..60 => format!("{seconds}s"),
573        60..3600 => format!("{}m{}s", seconds / 60, seconds % 60),
574        _ => format!("{}h{}m", seconds / 3600, seconds % 3600 / 60),
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581
582    #[test]
583    fn elapsed_time_reads_like_speech() {
584        assert_eq!(elapsed(web_time::Duration::from_secs(45)), "45s");
585        assert_eq!(elapsed(web_time::Duration::from_secs(380)), "6m20s");
586        assert_eq!(elapsed(web_time::Duration::from_secs(7500)), "2h5m");
587    }
588}