Skip to main content

gui/
main.rs

1//! # R.E.K.T.A.L. GUI Main Application
2//!
3//! Entry point and primary application state for the R.E.K.T.A.L. lighting control GUI.
4//! Handles startup initialization, logger configuration, window icon embedding,
5//! `eframe` viewport setup, docking tab management, and high-level layout rendering.
6
7use common::logging::LogLevel::*;
8use common::logging::{FileSink, Logger, TerminalSink};
9use common::networking::messages::UserRole;
10use common::networking::messages::{TcpClientMessage, TcpServerMessage};
11use common::networking::subscription_objects::SubscribeTopic::DMXConfiguration;
12use common::r_log;
13use eframe::egui;
14use egui_dock::{DockArea, DockState, TabViewer};
15use std::sync::mpsc::{Receiver, Sender};
16use std::sync::{mpsc, LazyLock, RwLock};
17use std::net::ToSocketAddrs;
18use crate::controller::{send_ui_event, UiEvent};
19use crate::network::udp_client;
20use crate::network::udp_client::MAX_CHANNEL;
21use crate::network::connection_state::ConnectionState::Connected;
22use network::connection_state::{ConnectionState, SessionState};
23use network::tcp_client::TcpClient;
24use panels::Tab;
25use crate::network::connection_state::SessionState::LoggedIn;
26
27mod controller;
28mod network;
29mod panels;
30
31/// Global thread-safe channel sender for dispatching [`UiEvent`]s across the application.
32pub static UI_EVENT_SENDER: LazyLock<RwLock<Option<Sender<UiEvent>>>> =
33    LazyLock::new(|| RwLock::new(None));
34
35/// Application main entry point.
36///
37/// Initializes logging sinks, embeds the window icon, configures native viewport options,
38/// and starts the `eframe` event loop with [`MyApp`].
39fn main() -> eframe::Result<()> {
40    let log_path = std::env::temp_dir().join("rektal_gui.log");
41    Logger::global().add_sink(Box::new(FileSink::new(log_path.to_str().unwrap_or("/tmp/rektal_gui.log"))));
42    Logger::global().add_sink(Box::new(TerminalSink { cli_prompt: None }));
43
44    let icon = {
45        let image = img_crate::load_from_memory(include_bytes!("../assets/rektal-logo-without-title-32px.png"))
46            .expect("Icon konnte nicht geladen werden")
47            .into_rgba8();
48        let width = image.width();
49        let height = image.height();
50        egui::viewport::IconData {
51            rgba: image.into_raw(),
52            width,
53            height,
54        }
55    };
56
57    let options = eframe::NativeOptions {
58        viewport: egui::ViewportBuilder::default()
59            .with_inner_size([1024.0, 768.0])
60            .with_title("R.E.K.T.A.L.")
61            .with_app_id("rektal")
62            .with_icon(std::sync::Arc::new(icon)),
63        ..Default::default()
64    };
65    r_log!(Info, "eframe initialized");
66
67    eframe::run_native(
68        "Docking App",
69        options,
70        Box::new(|cc| Ok(Box::new(MyApp::new(cc.egui_ctx.clone())))),
71    )
72}
73
74/// The main GUI application state structure.
75///
76/// Holds the docking state tree, user session parameters, network connection states,
77/// and active communication channels between threads.
78pub struct MyApp {
79    /// The docking state tree holding all open tabs (Terminals, Universes, etc.).
80    tree: DockState<Tab>,
81    /// Whether the session settings modal window is currently open.
82    show_session_settings: bool,
83    /// Whether the connection settings modal window is currently open.
84    show_connection_settings: bool,
85    /// The IP address and port of the target kernel server.
86    server_address: String,
87    /// The currently authenticated username.
88    username: String,
89    /// Counter assigned as the unique ID for the next newly created tab.
90    next_tab_id: u32,
91    /// Receiver channel for incoming DMX Universe packet data.
92    dmx_receiver: Receiver<(u8, [u8; MAX_CHANNEL])>,
93    /// Receiver channel for incoming TCP server messages.
94    tcp_listen_receiver: Option<Receiver<TcpServerMessage>>,
95    /// Sender channel for emitting UI events to the controller handler.
96    ui_event_sender: Sender<UiEvent>,
97    /// Receiver channel for consuming UI events in the main event loop.
98    ui_event_receiver: Receiver<UiEvent>,
99    /// Sender channel for transmitting TCP messages to the server.
100    tcp_write_sender: Option<Sender<TcpClientMessage>>,
101    /// The current state of the TCP network connection.
102    connection_state: ConnectionState,
103    /// The active user role (e.g. Programmer, Showrunner).
104    role: UserRole,
105    /// Password input buffer for authentication (cleared after login attempts).
106    password: String,
107    /// Temporary draft username used in the session settings window.
108    draft_username: String,
109    /// Temporary draft role selection used in the session settings window.
110    draft_role: UserRole,
111}
112
113impl MyApp {
114    /// Creates a new instance of [`MyApp`] initialized with default panels, channels, and listeners.
115    ///
116    /// # Arguments
117    /// * `ctx` - The `egui::Context` reference for UI repaint signals.
118    fn new(ctx: egui::Context) -> Self {
119        let (dmx_sender, dmx_receiver) = mpsc::channel();
120
121        let (ui_event_sender, ui_event_receiver) = mpsc::channel();
122
123        *UI_EVENT_SENDER.write().unwrap() = Some(ui_event_sender.clone());
124
125        if let Err(e) = udp_client::start_udp_listener(None, dmx_sender, ctx) {
126            r_log!(Error, "Failed to start UDP listener: {}", e);
127        }
128
129        let initial_terminal_panel = panels::terminal::TerminalPanel::new(0, false);
130
131        let mut app = Self {
132            tree: DockState::new(vec![Tab::Terminal(initial_terminal_panel)]),
133            show_session_settings: false,
134            show_connection_settings: false,
135            server_address: "127.0.0.1".to_string(),
136            username: "Default User".to_string(),
137            next_tab_id: 1,
138            dmx_receiver,
139            tcp_listen_receiver: None,
140            ui_event_sender,
141            ui_event_receiver,
142            tcp_write_sender: None,
143            connection_state: ConnectionState::Disconnected,
144            role: UserRole::Programmer,
145            password: String::new(),
146            draft_username: String::new(),
147            draft_role: UserRole::Programmer,
148        };
149
150        if let Some(target) = parse_args() {
151            app.server_address = target;
152            app.connect_to_server();
153        }
154
155        app
156    }
157
158    /// Spawns the TCP client background thread to connect to the configured `server_address`.
159    fn connect_to_server(&mut self) {
160        let (tcp_write_sender, tcp_write_receiver) = mpsc::channel();
161        let (tcp_listen_sender, tcp_listen_receiver) = mpsc::channel();
162        self.tcp_write_sender = Some(tcp_write_sender);
163        self.tcp_listen_receiver = Some(tcp_listen_receiver);
164
165        let server_address_clone = self.server_address.clone();
166        std::thread::spawn(move || {
167            let target_raw = if server_address_clone.contains(':') {
168                server_address_clone
169            } else {
170                format!("{}:6767", server_address_clone)
171            };
172
173            if target_raw.to_socket_addrs().is_ok() {
174                let mut tcp_client = TcpClient::new(
175                    target_raw,
176                    tcp_write_receiver,
177                    tcp_listen_sender,
178                );
179                tcp_client.start_tcp_client();
180            } else {
181                r_log!(Error, "Failed to resolve server address: {}", target_raw);
182                send_ui_event(UiEvent::SetConnectionState {
183                    state: ConnectionState::Error,
184                });
185            }
186        });
187    }
188
189    /// Renders the top menu bar containing connection options and window tab controls.
190    ///
191    /// # Arguments
192    /// * `ctx` - The `egui::Context` reference.
193    fn draw_top_bar(&mut self, ctx: &egui::Context) {
194        egui::TopBottomPanel::top("top_bar").show(ctx, |ui| {
195            egui::menu::bar(ui, |ui| {
196                ui.menu_button("Connection", |ui| {
197                    if ui.button("Connection Settings").clicked() {
198                        self.show_connection_settings = true;
199                        ui.close_menu();
200                    }
201                    if ui
202                        .add_enabled(
203                            matches!(self.connection_state, Connected { .. }),
204                            egui::Button::new("Session Settings"),
205                        )
206                        .clicked()
207                    {
208                        self.show_session_settings = true;
209                        self.draft_username = self.username.clone();
210                        self.draft_role = self.role.clone();
211                        self.password.clear(); // just to be safe
212                        ui.close_menu();
213                    }
214                    if ui
215                        .add_enabled(
216                            matches!(
217                                self.connection_state,
218                                Connected {
219                                    session_state: SessionState::LoggedIn
220                                }
221                            ),
222                            egui::Button::new("Logout"),
223                        )
224                        .clicked()
225                    {
226                        let event = UiEvent::LogoutRequest;
227                        if let Err(e) = self.ui_event_sender.send(event) {
228                            r_log!(Error, "Failed to send UiEvent: {}", e);
229                        }
230                    }
231                    if ui
232                        .add_enabled(
233                            !matches!(self.connection_state, ConnectionState::Disconnected),
234                            egui::Button::new("Disconnect"),
235                        )
236                        .clicked()
237                    {
238                        let event = UiEvent::DisconnectRequest;
239                        if let Err(e) = self.ui_event_sender.send(event) {
240                            r_log!(Error, "Failed to send UiEvent: {}", e);
241                        }
242                    }
243                });
244
245                ui.menu_button("Window", |ui| {
246
247                    if ui.button("Terminal").clicked() {
248                        let new_tab_id = self.next_tab_id;
249                        self.next_tab_id += 1;
250
251                        let new_terminal_panel = panels::terminal::TerminalPanel::new(
252                            new_tab_id,
253                            self.connection_state == Connected{session_state: LoggedIn});
254
255                        self.tree
256                            .main_surface_mut()
257                            .push_to_focused_leaf(Tab::Terminal(new_terminal_panel));
258                        ui.close_menu();
259                    }
260
261                    if ui.button("Universe").clicked() {
262                        let event = UiEvent::SubscribeRequest {topic: DMXConfiguration};
263                        if let Err(e) = self.ui_event_sender.send(event) {
264                            r_log!(Error, "Failed to send UiEvent: {}", e);
265                        }
266
267                        let new_tab_id = self.next_tab_id;
268                        self.next_tab_id += 1;
269
270                        let new_universe_panel = panels::universe::UniversePanel::new(new_tab_id);
271                        self.tree
272                            .main_surface_mut()
273                            .push_to_focused_leaf(Tab::Universe(new_universe_panel));
274                        ui.close_menu();
275                    }
276                });
277            });
278        });
279    }
280
281    /// Renders the central docking area managing all active application tabs.
282    ///
283    /// # Arguments
284    /// * `ctx` - The `egui::Context` reference.
285    fn draw_central_panel(&mut self, ctx: &egui::Context) {
286        egui::CentralPanel::default().show(ctx, |ui| {
287            let mut tab_viewer = MyTabViewer {};
288            DockArea::new(&mut self.tree)
289                .style(egui_dock::Style::from_egui(ui.style().as_ref()))
290                .show_inside(ui, &mut tab_viewer);
291        });
292    }
293
294    /// Renders the modal window for configuring server IP address and triggering connections.
295    ///
296    /// # Arguments
297    /// * `ctx` - The `egui::Context` reference.
298    fn draw_connection_settings(&mut self, ctx: &egui::Context) {
299        let mut is_connection_open = self.show_connection_settings;
300        let mut request_conn_close = false;
301
302        if is_connection_open {
303            egui::Window::new("Connection Settings")
304                .open(&mut is_connection_open)
305                .collapsible(false)
306                .resizable([false, false])
307                .pivot(egui::Align2::CENTER_CENTER)
308                .show(ctx, |ui| {
309                    egui::Grid::new("connection_seetings_grid")
310                        .num_columns(2)
311                        .show(ui, |ui| {
312                            ui.label("Server adress ");
313                            if ui.text_edit_singleline(&mut self.server_address).changed() {}
314                        });
315                    ui.add_space(10.0);
316
317                    ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
318                        if ui.button("Connect").clicked() {
319                            self.connect_to_server();
320                            request_conn_close = true;
321                        }
322                        if ui.button("Close").clicked() {
323                            request_conn_close = true;
324                        }
325                    });
326                });
327        }
328
329        if request_conn_close {
330            is_connection_open = false;
331        }
332        self.show_connection_settings = is_connection_open;
333    }
334
335    /// Renders the modal window for user authentication and role selection.
336    ///
337    /// # Arguments
338    /// * `ctx` - The `egui::Context` reference.
339    fn draw_session_settings(&mut self, ctx: &egui::Context) {
340        let mut is_session_open = self.show_session_settings;
341        let mut request_close = false;
342        if is_session_open {
343            egui::Window::new("Session Settings")
344                .open(&mut is_session_open)
345                .collapsible(false)
346                .resizable([false, false])
347                .pivot(egui::Align2::CENTER_CENTER)
348                .show(ctx, |ui| {
349                    egui::Grid::new("session_seetings_grid")
350                        .num_columns(2)
351                        .show(ui, |ui| {
352                            ui.label("Username ");
353                            ui.text_edit_singleline(&mut self.draft_username);
354                            ui.end_row();
355
356                            ui.label("Role");
357                            egui::ComboBox::from_id_source("role_combo")
358                                .selected_text(match self.draft_role {
359                                    UserRole::Programmer => "Programmer",
360                                    UserRole::BlindProgrammer => "Programmer Blind",
361                                    UserRole::Showrunner => "Showrunner",
362                                })
363                                .show_ui(ui, |ui| {
364                                    ui.selectable_value(
365                                        &mut self.draft_role,
366                                        UserRole::Programmer,
367                                        "Programmer",
368                                    );
369                                    ui.selectable_value(
370                                        &mut self.draft_role,
371                                        UserRole::BlindProgrammer,
372                                        "Programmer Blind",
373                                    );
374                                    ui.selectable_value(
375                                        &mut self.draft_role,
376                                        UserRole::Showrunner,
377                                        "Showrunner",
378                                    );
379                                });
380                            ui.end_row();
381
382                            ui.label("Password");
383                            let password_edit =
384                                egui::TextEdit::singleline(&mut self.password).password(true);
385                            ui.add(password_edit);
386                            ui.end_row();
387                        });
388                    ui.add_space(10.0);
389
390                    if let Connected {
391                        session_state: SessionState::LoginFailed(ref reason),
392                    } = self.connection_state
393                    {
394                        ui.label(
395                            egui::RichText::new(format!("Login fehlgeschlagen: {}", reason))
396                                .color(egui::Color32::RED),
397                        );
398                        ui.add_space(10.0);
399                    }
400
401                    ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
402                        if ui.button("Login").clicked() {
403                            self.username = self.draft_username.clone();
404                            self.role = self.draft_role.clone();
405
406                            let event = UiEvent::LoginRequest {
407                                user_name: self.username.clone(),
408                                password: self.password.clone(),
409                                user_role: self.role.clone(),
410                            };
411                            if let Err(e) = self.ui_event_sender.send(event) {
412                                r_log!(Error, "Failed to send UiEvent: {}", e);
413                            } else {
414                                request_close = true;
415                            }
416                            self.password.clear();
417                        }
418                        if ui.button("Close").clicked() {
419                            request_close = true;
420                        }
421                    });
422                });
423        }
424        if request_close {
425            is_session_open = false;
426        }
427        self.show_session_settings = is_session_open;
428    }
429
430    /// Renders the bottom status bar displaying connection status indicator, username, role, and application version.
431    ///
432    /// # Arguments
433    /// * `ctx` - The `egui::Context` reference.
434    fn draw_bottom_bar(&mut self, ctx: &egui::Context) {
435        egui::TopBottomPanel::bottom("bottom_bar").show(ctx, |ui| {
436            ui.horizontal(|ui| {
437                let status_color = match &self.connection_state {
438                    ConnectionState::Disconnected | ConnectionState::Error => egui::Color32::RED,
439                    ConnectionState::ConnectionPending => egui::Color32::YELLOW,
440                    Connected { session_state } => match session_state {
441                        SessionState::LoginFailed(_) => egui::Color32::YELLOW,
442                        SessionState::LoggedIn => egui::Color32::GREEN,
443                        SessionState::LoginPending | SessionState::LoggedOut => {
444                            egui::Color32::YELLOW
445                        }
446                    },
447                };
448                let (rect, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover());
449                ui.painter().circle_filled(rect.center(), 4.0, status_color);
450                if let Connected {
451                    session_state: SessionState::LoggedIn,
452                } = self.connection_state
453                {
454                    ui.label(format!("{} | {}", self.username, self.role.to_string()));
455                } else {
456                    ui.label("Logged out");
457                }
458
459                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
460                    ui.label(format!(
461                        "R.E.K.T.A.L. Version: {}",
462                        env!("CARGO_PKG_VERSION")
463                    ));
464                });
465            });
466        });
467    }
468}
469
470impl eframe::App for MyApp {
471    /// Main frame update callback invoked on every UI render pass.
472    ///
473    /// Processes queued DMX packets, network server messages, and UI events,
474    /// then renders all UI components and panels.
475    fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
476        controller::handle_dmx_data(&self.dmx_receiver, &mut self.tree);
477        controller::handle_incoming_network_data(
478            &mut self.tcp_listen_receiver,
479            &mut self.tree,
480        );
481        controller::handle_events(
482            &self.ui_event_receiver,
483            &mut self.tcp_write_sender,
484            &mut self.connection_state,
485            &mut self.tree,
486        );
487
488        self.draw_top_bar(ctx);
489        self.draw_bottom_bar(ctx);
490        self.draw_central_panel(ctx);
491        self.draw_connection_settings(ctx);
492        self.draw_session_settings(ctx);
493    }
494
495    /// Application exit callback.
496    ///
497    /// Sends logout and disconnect requests to clean up network connections cleanly before shutdown.
498    fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
499        r_log!(Info, "Shutting down..");
500
501        if let Some(sender) = self.tcp_write_sender.take() {
502            let _ = sender.send(TcpClientMessage::Logout);
503        }
504
505        send_ui_event(UiEvent::DisconnectRequest);
506    }
507}
508
509/// Parses CLI command-line arguments to extract an auto-connect target address (`-c`, `--connect`, `-conn`).
510///
511/// # Returns
512/// `Option<String>` containing the server target address if supplied in CLI arguments.
513fn parse_args() -> Option<String> {
514    let args: Vec<String> = std::env::args().collect();
515    let mut iter = args.iter();
516
517    while let Some(arg) = iter.next() {
518        if arg == "-c" || arg == "--connect" || arg == "-conn" {
519            if let Some(target) = iter.next() {
520                return Some(target.clone());
521            }
522        }
523    }
524    None
525}
526
527/// Custom `egui_dock::TabViewer` implementation for rendering docking tabs.
528struct MyTabViewer;
529
530impl TabViewer for MyTabViewer {
531    type Tab = Tab;
532
533    /// Returns the header title for the given tab.
534    fn title(&mut self, tab: &mut Self::Tab) -> egui::WidgetText {
535        tab.title().into()
536    }
537
538    /// Renders the inner content UI of the tab.
539    fn ui(&mut self, ui: &mut egui::Ui, tab: &mut Self::Tab) {
540        tab.ui(ui);
541    }
542
543    /// Returns the unique `egui::Id` identifier for tab tracking.
544    fn id(&mut self, tab: &mut Self::Tab) -> egui::Id {
545        egui::Id::new(tab.unique_id())
546    }
547}