pub(crate) mod client_timing;
pub(crate) mod config;
pub(crate) mod effects;
pub(crate) mod menu;
pub(crate) mod no_project;
pub(crate) mod preview;
pub(crate) mod project_functions;
pub(crate) mod status_bar;
pub(crate) mod storage;
pub(crate) mod svg;
pub(crate) mod timing {
pub(crate) use gled_model::timing::BeatState;
}
use crate::ui::frame_requests::FrameRequests;
use crate::{
extension::AppExtension, link::Connection, model::ClientModel, settings::ClientSettings,
};
use crate::{
key_code::key_code,
pipeline::{
renderer_callback::RendererCallback,
thumbnails::{ThumbnailTextures, UiPreview},
ui_gpu,
},
ui::{
asset_tree::AssetTree, local_action::LocalAction, project_edits::project_edits,
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 gled_gpu::{
effect_state::EffectPipelines,
scene_render::{EvalContext, ensure_project_matrix_pipelines, render_matrices},
};
use gled_model::{
asset::{
Asset, output_device::routing::OutputRoutings, palette::Palette, project::Project,
scene::grid::GridLocation,
},
asset_id::AssetId,
input::key_code::KeyCode,
};
use gled_protocol::action::UiAction;
use gled_protocol::state::OutputConfig;
use gled_protocol::state::{ClientInput, EngineEvent, ServerSettings};
use kanal::{Receiver, Sender};
use std::collections::{BTreeSet, HashSet};
use std::sync::Arc;
use storage::{show_storage_error, show_storage_loading};
use wgpu::CommandEncoderDescriptor;
#[derive(Debug, Default)]
struct EngineSync {
project: Option<Project>,
routings: Arc<OutputRoutings>,
keys: Option<BTreeSet<KeyCode>>,
layout_generation: u64,
}
#[derive(Debug)]
pub struct App {
pub(crate) windows: Windows,
pub(crate) timing: ClientTiming,
pub(crate) connection: Connection,
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),
pub(crate) settings: ClientSettings,
pub(crate) output_config: OutputConfig,
pub(crate) model: ClientModel,
frame_requests: FrameRequests,
pub(crate) preview_has_output: bool,
pub(crate) svg_runtime: svg::SvgRuntime,
pub(crate) thumbnails: EffectPipelines,
pub(crate) thumbnail_textures: ThumbnailTextures,
pub(crate) ui_preview: UiPreview,
}
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()));
#[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.model.layout);
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,
&self.model.layout,
);
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());
ctx.request_repaint();
}
}
impl App {
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();
}
fn send_local_changes(&mut self) {
for action in project_edits(&self.sync.project, &self.project) {
action.enqueue();
}
self.sync.project = self.project.clone();
if self.output_config.routings != self.sync.routings {
self.sync.routings = self.output_config.routings.clone();
UiAction::SetOutputRoutings(self.output_config.routings.clone()).enqueue();
}
}
fn receive_engine_messages(&mut self) {
for message in self.connection.receive() {
self.model.apply(message);
}
if std::mem::take(&mut self.model.welcomed) {
self.connection.reset_sequence();
}
}
fn receive_engine_state(&mut self) {
if !std::mem::take(&mut self.model.state_changed) || !self.model.replica.complete {
return;
}
let replica = std::mem::take(&mut self.model.replica);
let state = &replica.state;
if state.project_id != self.project_id {
self.svg_runtime.reset();
self.selected_scene_effect_editor.reset();
}
self.project_id = state.project_id;
if state.selected_scene_instance != self.selected_scene_instance {
self.selected_scene_instance = state.selected_scene_instance;
self.selected_scene_effect_editor.reset();
}
self.network_stats = (
f64::from(state.status.network_in),
f64::from(state.status.network_out),
);
self.blackout = state.blackout;
self.blackout_hold = state.blackout_hold;
self.midi_output_active = state.midi_output_active;
self.timing.update(state.beat, state.tap_count);
self.output_config.universes = state.output_config.universes.clone();
if replica.applied_sequence >= self.connection.sent_sequence()
&& (state.project != self.sync.project
|| state.output_config.routings != self.sync.routings)
{
if state.project.is_none() && self.project.is_some() {
self.windows.external_device_settings.close();
self.windows.output_routings.close();
self.windows.shortcuts.close();
}
self.project = state.project.clone();
self.sync.project = state.project.clone();
self.output_config.routings = state.output_config.routings.clone();
self.sync.routings = state.output_config.routings.clone();
}
self.model.replica = replica;
if self.model.layout.generation() != self.sync.layout_generation {
self.sync.layout_generation = self.model.layout.generation();
self.ui_preview.indices.send_positions();
}
}
pub(crate) fn server_settings(&self) -> &ServerSettings {
&self.model.replica.state.status.settings
}
pub(crate) fn change_server_settings(&self, change: impl FnOnce(&mut ServerSettings)) {
let mut settings = self.server_settings().clone();
change(&mut settings);
ClientInput::SetServerSettings(settings).enqueue();
}
fn connection_banner(&self, ui: &mut egui::Ui, viewport_id: Option<ViewportId>) {
if !self.connection.is_remote() {
return;
}
ui.ctx()
.request_repaint_after(web_time::Duration::from_secs(1));
let Some(interruption) = self.connection.interruption() else {
return;
};
egui::Panel::top(format!("{viewport_id:?} connection"))
.frame(
egui::Frame::new()
.fill(egui::Color32::DARK_RED)
.inner_margin(egui::Margin::same(4)),
)
.show(ui, |ui| {
ui.vertical_centered(|ui| {
ui.colored_label(
egui::Color32::WHITE,
format!(
"Connection to the engine interrupted, last contact {} ago. \
Reconnecting…",
elapsed(interruption.last_contact)
),
);
});
});
}
pub(crate) fn loading(&self) -> Option<String> {
if !self.model.replica.complete {
return Some(match self.connection.target() {
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(),
}
}
}
fn handle_engine_events(&mut self) {
for event in std::mem::take(&mut self.model.events) {
match event {
EngineEvent::ShaderReloaded(animation) => {
self.thumbnails.invalidate(animation);
self.windows.scenes.invalidate_pipelines(animation);
}
EngineEvent::Error(error) => {
#[cfg(not(target_arch = "wasm32"))]
if let Err(err) = notify_rust::Notification::new()
.summary("Error")
.body(&error)
.icon("application-gled")
.show()
{
tracing::error!("Could not show notification: {err:?}");
}
self.windows.errors.entries.push(error);
}
}
}
}
fn send_keys(&mut self, ctx: &egui::Context) {
if ctx.egui_wants_keyboard_input() {
return;
}
let keys: BTreeSet<KeyCode> =
ctx.input(|input| input.keys_down.iter().copied().map(key_code).collect());
if self.sync.keys.as_ref() != Some(&keys) {
self.sync.keys = Some(keys.clone());
ClientInput::Keys(keys).enqueue();
}
}
#[cfg_attr(feature = "profiling", profiling::function)]
fn render_thumbnails_and_preview(&mut self) {
let gpu = ui_gpu();
let mut encoder = gpu
.device
.create_command_encoder(&CommandEncoderDescriptor {
label: Some("Render thumbnails and preview"),
});
if let Some(project) = &self.project {
ensure_project_matrix_pipelines(
project,
&mut self.thumbnails,
&gpu,
&self.model.replica.assets,
);
render_matrices(
project,
&mut self.thumbnails,
&gpu,
&mut encoder,
&self.timing.beat_state(),
EvalContext {
layout: &self.model.layout,
collections: &self.model.replica.assets,
sound: &*self.model.sound,
},
);
self.thumbnail_textures.sync(&self.thumbnails);
}
let latest_output = self.model.preview.take();
match (self.project.is_some(), latest_output) {
(true, Some(output)) => {
self.ui_preview.preview.upload_output(&gpu.queue, &output);
self.preview_has_output = true;
}
(false, _) if self.preview_has_output => {
self.ui_preview.preview.clear_output(&gpu.queue);
self.preview_has_output = false;
}
_ => {}
}
self.ui_preview
.indices
.prepare(&gpu.queue, &self.model.layout);
self.ui_preview.indices.run(&mut encoder);
self.ui_preview.preview.run(&mut encoder);
RendererCallback::try_add(encoder.finish());
}
#[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);
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);
}
});
}
pub fn new(
connection: Connection,
settings: ClientSettings,
extension: Option<Box<dyn AppExtension>>,
) -> Self {
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,
svg_runtime: Default::default(),
thumbnails: EffectPipelines::default(),
thumbnail_textures: ThumbnailTextures::default(),
ui_preview: UiPreview::new(&crate::wgpu_render_state()),
}
}
}
fn elapsed(duration: web_time::Duration) -> String {
let seconds = duration.as_secs();
match seconds {
0..60 => format!("{seconds}s"),
60..3600 => format!("{}m{}s", seconds / 60, seconds % 60),
_ => format!("{}h{}m", seconds / 3600, seconds % 3600 / 60),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn elapsed_time_reads_like_speech() {
assert_eq!(elapsed(web_time::Duration::from_secs(45)), "45s");
assert_eq!(elapsed(web_time::Duration::from_secs(380)), "6m20s");
assert_eq!(elapsed(web_time::Duration::from_secs(7500)), "2h5m");
}
}