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}