Skip to main content

gui/panels/
universe.rs

1//! # Universe Panel Module
2//!
3//! This module implements the visual DMX Universe view for the R.E.K.T.A.L. GUI application.
4//! It renders up to 512 DMX channels per universe in a dynamic, responsive grid layout,
5//! displays live channel values, and optionally overlays patched fixture devices and property types.
6
7use crate::network::udp_client::MAX_CHANNEL;
8pub use common::fixture::PropertyType;
9use common::networking::subscription_objects::{
10    DMXConfigForClientState, DMXConfigurationForClient,
11};
12use eframe::egui;
13
14/// UI Panel representing a single DMX universe view in the docking interface.
15///
16/// Displays a responsive grid of 512 DMX channels ([`MAX_CHANNEL`]), supports switching
17/// between universes 1 through 16, and dynamically renders patched fixture overlays.
18#[derive(Clone)]
19pub struct UniversePanel {
20    /// The unique ID of the tab hosting this universe panel instance.
21    pub tab_id: u32,
22    /// Currently selected universe index (1-based, 1..=16).
23    pub selected_universe: u8,
24    /// Live DMX byte values for all 512 channels of the active universe.
25    pub dmx_data: [u8; MAX_CHANNEL],
26    /// Configuration mapping fixtures to DMX channels received from the kernel server.
27    pub device_configuration: Option<DMXConfigForClientState>,
28    /// Controls whether the settings configuration window is visible.
29    settings_open: bool,
30    /// Minimum width of an individual DMX channel cell in pixels.
31    min_pure_cell_width: f32,
32    /// Height of an individual DMX channel cell in pixels.
33    cell_height: f32,
34    /// Flag enabling or disabling the rendering of patched fixture properties on cells.
35    show_device_properties: bool,
36    /// Holds the name of the fixture currently hovered by the mouse cursor.
37    hovered_device: Option<String>,
38}
39
40impl UniversePanel {
41    /// Creates a new [`UniversePanel`] instance with default settings for a given tab ID.
42    ///
43    /// # Arguments
44    /// * `tab_id` - The unique identifier of the tab hosting this panel.
45    pub fn new(tab_id: u32) -> Self {
46        Self {
47            tab_id,
48            selected_universe: 1,
49            dmx_data: [0; MAX_CHANNEL],
50            settings_open: false,
51            min_pure_cell_width: 45.0,
52            cell_height: 30.0,
53            show_device_properties: false,
54            device_configuration: None,
55            hovered_device: None,
56        }
57    }
58
59    /// Renders the main universe panel UI inside an `egui` container.
60    ///
61    /// # Arguments
62    /// * `ui` - Mutable reference to the `egui::Ui` layout context.
63    pub fn ui(&mut self, ui: &mut egui::Ui) {
64        let panel_id = ui
65            .id()
66            .with(format!("universe_content_area_{}", self.tab_id));
67
68        ui.push_id(panel_id, |ui| {
69            ui.horizontal(|ui| {
70                ui.label(egui::RichText::new("Universe").strong());
71                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
72                    if ui.button("⚙").on_hover_text("Settings").clicked() {
73                        self.settings_open = true;
74                    }
75                });
76            });
77
78            self.draw_settings_frame(ui);
79
80            ui.horizontal_wrapped(|ui| {
81                for i in 1..17 {
82                    let is_selected = self.selected_universe == i;
83                    if ui.selectable_label(is_selected, format!("{}", i)).clicked() {
84                        if self.selected_universe != i {
85                            self.selected_universe = i;
86                            self.dmx_data = [0; MAX_CHANNEL]; // Clear data immediately on switch
87                        }
88                    }
89                }
90            });
91
92            ui.add_space(8.0);
93
94            self.draw_dmx_cell_pane(ui);
95        });
96    }
97
98    /// Renders the pop-up settings window for configuring universe panel display options.
99    ///
100    /// # Arguments
101    /// * `ui` - Mutable reference to the `egui::Ui` context.
102    fn draw_settings_frame(&mut self, ui: &mut egui::Ui) {
103        if self.settings_open {
104            let mut is_open = self.settings_open;
105
106            egui::Window::new(format!("Universe Panel Settings - Panel {}", self.tab_id))
107                .collapsible(false)
108                .resizable([false, false])
109                .pivot(egui::Align2::CENTER_CENTER)
110                .show(ui.ctx(), |ui| {
111                    egui::Grid::new(format!("seetings_grid_{}", self.tab_id))
112                        .num_columns(2)
113                        .show(ui, |ui| {
114                            if ui
115                                .checkbox(
116                                    &mut self.show_device_properties,
117                                    "show devices & properties",
118                                )
119                                .changed()
120                            {
121                                self.cell_height = match self.show_device_properties {
122                                    true => 60.0,
123                                    false => 30.0,
124                                }
125                            }
126                        });
127                    ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
128                        if ui.button("Back").on_hover_text("Back").clicked() {
129                            is_open = false;
130                        }
131                    });
132                    false
133                });
134            self.settings_open = is_open;
135        }
136    }
137
138    /// Renders the scrollable DMX channel grid and fixture overlays.
139    ///
140    /// Dynamically calculates the optimal number of columns based on available UI width,
141    /// populates all 512 channel cells, and paints fixture overlay banners across patched channels.
142    ///
143    /// # Arguments
144    /// * `ui` - Mutable reference to the `egui::Ui` context.
145    fn draw_dmx_cell_pane(&mut self, ui: &mut egui::Ui) {
146        let spacing = 4.0;
147        let frame_extra = 6.0;
148        let min_full_cell_width = self.min_pure_cell_width + frame_extra;
149
150        let available_width = (ui.available_width() - 4.0).max(0.0);
151
152        let num_columns =
153            ((available_width + spacing) / (min_full_cell_width + spacing)).floor() as usize;
154        let num_columns = num_columns.max(1);
155
156        let total_spacing = num_columns.saturating_sub(1) as f32 * spacing;
157        let stretched_full_cell_width = (available_width - total_spacing) / (num_columns as f32);
158
159        let stretched_pure_cell_width = stretched_full_cell_width - frame_extra;
160
161        let total_rows = (MAX_CHANNEL + num_columns - 1) / num_columns;
162        let row_height = self.cell_height + spacing;
163        let mut cell_responses = vec![None; MAX_CHANNEL];
164
165        egui::ScrollArea::vertical()
166            .id_source(format!("dmx_scroll_{}", self.tab_id))
167            .auto_shrink([false, false])
168            .scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden)
169            .show_rows(ui, row_height, total_rows, |ui, row_range| {
170                egui::Grid::new(format!("dmx_grid_{}", self.tab_id))
171                    .num_columns(num_columns)
172                    .spacing([spacing, spacing])
173                    .min_col_width(stretched_full_cell_width)
174                    .max_col_width(stretched_full_cell_width)
175                    .show(ui, |ui| {
176                        for row in row_range {
177                            for col in 0..num_columns {
178                                let i = row * num_columns + col;
179                                if i < MAX_CHANNEL {
180                                    let dmx_config = self
181                                        .device_configuration
182                                        .as_ref()
183                                        .and_then(|config| {
184                                            config.get(self.selected_universe as usize - 1)
185                                        })
186                                        .and_then(|universe| universe.get(i))
187                                        .cloned()
188                                        .unwrap_or(DMXConfigurationForClient::Empty);
189
190                                    let resp = self.draw_dmx_cell(
191                                        ui,
192                                        i + 1,
193                                        dmx_config,
194                                        self.dmx_data[i],
195                                        stretched_pure_cell_width,
196                                    );
197                                    cell_responses[i] = Some(resp);
198                                } else {
199                                    ui.label("");
200                                }
201                            }
202                            ui.end_row();
203                        }
204                    });
205
206                let cell_rects: Vec<Option<egui::Rect>> = cell_responses
207                    .iter()
208                    .map(|r| r.as_ref().map(|resp| resp.rect))
209                    .collect();
210
211                // Draw device overlays inside the ScrollArea so they scroll with the content
212                if self.show_device_properties {
213                    if let Some(universe_config) = self
214                        .device_configuration
215                        .as_ref()
216                        .and_then(|config| config.get(self.selected_universe as usize - 1))
217                    {
218                        let mut i = 0;
219                        while i < MAX_CHANNEL {
220                            let entry = universe_config.get(i);
221                            if let Some(DMXConfigurationForClient::Reserved {
222                                fixture_type_hash,
223                                fixture_name,
224                                ..
225                            }) = entry
226                            {
227                                let start = i;
228                                let mut end = i;
229                                while end + 1 < MAX_CHANNEL {
230                                    if let Some(DMXConfigurationForClient::Reserved {
231                                        fixture_name: next_name,
232                                        ..
233                                    }) = universe_config.get(end + 1)
234                                    {
235                                        if next_name == fixture_name {
236                                            end += 1;
237                                            continue;
238                                        }
239                                    }
240                                    break;
241                                }
242
243                                self.draw_device_overlay(
244                                    ui,
245                                    start,
246                                    end,
247                                    fixture_name.as_str(),
248                                    *fixture_type_hash,
249                                    &cell_rects,
250                                    num_columns,
251                                );
252                                i = end + 1;
253                            } else {
254                                i += 1;
255                            }
256                        }
257                    }
258
259                    // Detect which device is currently hovered
260                    let mut hovered_device_name = None;
261                    if let Some(universe_config) = self
262                        .device_configuration
263                        .as_ref()
264                        .and_then(|config| config.get(self.selected_universe as usize - 1))
265                    {
266                        for (i, resp_opt) in cell_responses.iter().enumerate() {
267                            if let Some(resp) = resp_opt {
268                                if resp.hovered() {
269                                    if let Some(DMXConfigurationForClient::Reserved {
270                                        fixture_name,
271                                        ..
272                                    }) = universe_config.get(i)
273                                    {
274                                        hovered_device_name = Some(fixture_name.clone());
275                                        break;
276                                    }
277                                }
278                            }
279                        }
280                    }
281
282                    if self.hovered_device != hovered_device_name {
283                        self.hovered_device = hovered_device_name;
284                        ui.ctx().request_repaint();
285                    }
286                }
287            });
288    }
289
290    /// Paints a visual banner overlay spanning across consecutive DMX channels occupied by a single fixture device.
291    ///
292    /// # Arguments
293    /// * `ui` - Mutable reference to the `egui::Ui` context.
294    /// * `start` - Starting DMX channel index (0-based).
295    /// * `end` - Ending DMX channel index (0-based, inclusive).
296    /// * `fixture_name` - Display name of the patched fixture device.
297    /// * `fixture_type_hash` - Hash byte used to generate a unique fixture color.
298    /// * `cell_rects` - Array of cell bounding rectangles for computing overlay spans.
299    /// * `num_columns` - Current number of columns in the grid layout.
300    fn draw_device_overlay(
301        &self,
302        ui: &mut egui::Ui,
303        start: usize,
304        end: usize,
305        fixture_name: &str,
306        fixture_type_hash: u8,
307        cell_rects: &[Option<egui::Rect>],
308        num_columns: usize,
309    ) {
310        let color = get_color_for_fixture_hash(fixture_type_hash);
311        let border_color = color.to_opaque();
312
313        let mut current_start = start;
314        while current_start <= end {
315            let row = current_start / num_columns;
316            let row_end = ((row + 1) * num_columns - 1).min(end);
317
318            let mut row_rect: Option<egui::Rect> = None;
319            for c in current_start..=row_end {
320                if let Some(rect) = cell_rects[c] {
321                    if let Some(ref mut r) = row_rect {
322                        *r = r.union(rect);
323                    } else {
324                        row_rect = Some(rect);
325                    }
326                }
327            }
328
329            if let Some(rect) = row_rect {
330                let bar_height = 28.0;
331                let bar_rect = egui::Rect::from_min_max(
332                    egui::pos2(rect.left(), rect.top() + 13.0),
333                    egui::pos2(rect.right(), rect.top() + 13.0 + bar_height),
334                );
335
336                // Highlight background and stroke if this device is hovered
337                let is_hovered = Some(fixture_name) == self.hovered_device.as_deref();
338                let bg_color = if is_hovered {
339                    egui::Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), 120)
340                } else {
341                    color
342                };
343                let stroke_width: f32 = if is_hovered { 2.0 } else { 1.0 };
344
345                ui.painter().rect(
346                    bar_rect,
347                    2.0,
348                    bg_color,
349                    egui::Stroke::new(stroke_width, border_color),
350                );
351
352                let display_name = if current_start == start {
353                    fixture_name.to_string()
354                } else {
355                    format!("{} (cont.)", fixture_name)
356                };
357
358                let font_size = 10.0;
359                let text_pos = bar_rect.center_top() + egui::vec2(0.0, 2.0);
360                ui.painter().text(
361                    text_pos,
362                    egui::Align2::CENTER_TOP,
363                    display_name,
364                    egui::FontId::monospace(font_size),
365                    border_color,
366                );
367            }
368
369            current_start = row_end + 1;
370        }
371    }
372
373    /// Renders a single DMX channel cell inside the grid.
374    ///
375    /// Displays channel number, live byte value, and optional patched property details.
376    ///
377    /// # Arguments
378    /// * `ui` - Mutable reference to the `egui::Ui` context.
379    /// * `channel_num` - 1-based DMX channel number (1..=512).
380    /// * `dmx_config` - Patched fixture configuration for this specific channel.
381    /// * `val` - Live 8-bit DMX value (0..=255).
382    /// * `width` - Target pixel width of the cell frame.
383    ///
384    /// # Returns
385    /// An `egui::Response` representing user interaction with the cell frame.
386    fn draw_dmx_cell(
387        &mut self,
388        ui: &mut egui::Ui,
389        channel_num: usize,
390        dmx_config: DMXConfigurationForClient,
391        val: u8,
392        width: f32,
393    ) -> egui::Response {
394        let is_hovered_device = if let Some(ref hovered) = self.hovered_device {
395            if let DMXConfigurationForClient::Reserved { fixture_name, .. } = &dmx_config {
396                fixture_name == hovered
397            } else {
398                false
399            }
400        } else {
401            false
402        };
403
404        let fill_color = if is_hovered_device {
405            ui.visuals().widgets.hovered.bg_fill.linear_multiply(0.5)
406        } else {
407            ui.visuals().faint_bg_color
408        };
409
410        let stroke = if is_hovered_device {
411            egui::Stroke::new(1.5, ui.visuals().widgets.hovered.bg_stroke.color)
412        } else {
413            ui.visuals().widgets.noninteractive.bg_stroke
414        };
415
416        egui::Frame::none()
417            .fill(fill_color)
418            .rounding(2.0)
419            .inner_margin(2.0)
420            .stroke(stroke)
421            .show(ui, |ui| {
422                ui.set_width(width);
423                ui.set_height(self.cell_height);
424                ui.vertical_centered(|ui| {
425                    // Align the channel number to the top right of the cell to avoid overlapping with the device name
426                    ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| {
427                        ui.label(
428                            egui::RichText::new(channel_num.to_string())
429                                .size(8.0)
430                                .weak(),
431                        );
432                    });
433
434                    // Lays out the remaining elements from the bottom up to align them to the bottom
435                    ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| {
436                        ui.label(
437                            egui::RichText::new(val.to_string())
438                                .strong()
439                                .size(14.0)
440                                .monospace(),
441                        );
442                        ui.add_space(4.0);
443                        if self.show_device_properties {
444                            match &dmx_config {
445                                DMXConfigurationForClient::Reserved { property_type, .. } => {
446                                    let prop_str = match property_type {
447                                        PropertyType::Simple(simple) => format!("{:?}", simple),
448                                        PropertyType::Color(color) => format!("{:?}", color),
449                                    };
450                                    ui.label(egui::RichText::new(prop_str).size(9.0));
451                                }
452                                DMXConfigurationForClient::Empty => {
453                                    ui.label(egui::RichText::new("-").size(9.0).weak());
454                                }
455                            }
456                        }
457                    });
458                });
459            })
460            .response
461    }
462}
463
464/// Generates a deterministic semi-transparent [`egui::Color32`] hue based on a fixture hash byte.
465///
466/// # Arguments
467/// * `hash` - Hash byte representing the fixture type.
468///
469/// # Returns
470/// A semi-transparent `egui::Color32` color for rendering fixture overlay banners.
471fn get_color_for_fixture_hash(hash: u8) -> egui::Color32 {
472    use egui::ecolor::Hsva;
473    let hue = (hash as f32) / 255.0;
474    let hsva = Hsva::new(hue, 0.8, 0.8, 0.25);
475    egui::Color32::from(hsva)
476}