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
12pub(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#[derive(Debug, Default)]
61struct EngineSync {
62 project: Option<Project>,
64 routings: Arc<OutputRoutings>,
66 keys: Option<BTreeSet<KeyCode>>,
68 layout_generation: u64,
70}
71
72#[derive(Debug)]
73pub struct App {
74 pub(crate) windows: Windows,
75 pub(crate) timing: ClientTiming,
76 pub(crate) connection: Connection,
78 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 pub(crate) settings: ClientSettings,
101 pub(crate) output_config: OutputConfig,
102 pub(crate) model: ClientModel,
104 frame_requests: FrameRequests,
106 pub(crate) preview_has_output: bool,
109 pub(crate) svg_runtime: svg::SvgRuntime,
110 pub(crate) thumbnails: EffectPipelines,
112 pub(crate) thumbnail_textures: ThumbnailTextures,
114 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 #[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 ctx.request_repaint();
214 }
215}
216
217impl App {
218 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 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 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 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 pub(crate) fn server_settings(&self) -> &ServerSettings {
303 &self.model.replica.state.status.settings
304 }
305
306 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 fn connection_banner(&self, ui: &mut egui::Ui, viewport_id: Option<ViewportId>) {
316 if !self.connection.is_remote() {
317 return;
318 }
319 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 pub(crate) fn loading(&self) -> Option<String> {
348 if !self.model.replica.complete {
349 return Some(match self.connection.target() {
350 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 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 #[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 pub fn new(
520 connection: Connection,
521 settings: ClientSettings,
522 extension: Option<Box<dyn AppExtension>>,
523 ) -> Self {
524 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
568fn 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}