Skip to main content

egui_cha_ds/atoms/visual/
output_router.rs

1//! OutputRouter atom - Multi-output routing display for VJ applications
2//!
3//! A component for visualizing and controlling routing between sources and outputs.
4//! Supports multiple displays, NDI outputs, recording, and streaming destinations.
5
6use crate::Theme;
7use egui::{Color32, Pos2, Rect, Sense, Stroke, Ui, Vec2};
8use egui_cha::ViewCtx;
9
10/// Output type
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
12pub enum OutputType {
13    #[default]
14    Display,
15    NDI,
16    Record,
17    Stream,
18    Syphon,
19    Spout,
20    Virtual,
21}
22
23impl OutputType {
24    pub fn icon(&self) -> &'static str {
25        match self {
26            OutputType::Display => "🖥",
27            OutputType::NDI => "📡",
28            OutputType::Record => "⏺",
29            OutputType::Stream => "📺",
30            OutputType::Syphon => "🔗",
31            OutputType::Spout => "🔗",
32            OutputType::Virtual => "📦",
33        }
34    }
35
36    pub fn label(&self) -> &'static str {
37        match self {
38            OutputType::Display => "Display",
39            OutputType::NDI => "NDI",
40            OutputType::Record => "Record",
41            OutputType::Stream => "Stream",
42            OutputType::Syphon => "Syphon",
43            OutputType::Spout => "Spout",
44            OutputType::Virtual => "Virtual",
45        }
46    }
47}
48
49/// Source type
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
51pub enum SourceType {
52    #[default]
53    Main,
54    Preview,
55    Layer,
56    Aux,
57}
58
59impl SourceType {
60    pub fn label(&self) -> &'static str {
61        match self {
62            SourceType::Main => "Main",
63            SourceType::Preview => "Preview",
64            SourceType::Layer => "Layer",
65            SourceType::Aux => "Aux",
66        }
67    }
68}
69
70/// A routing source
71#[derive(Debug, Clone)]
72pub struct RouteSource {
73    pub id: String,
74    pub name: String,
75    pub source_type: SourceType,
76    pub color: Option<Color32>,
77}
78
79impl RouteSource {
80    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
81        Self {
82            id: id.into(),
83            name: name.into(),
84            source_type: SourceType::Main,
85            color: None,
86        }
87    }
88
89    pub fn with_type(mut self, source_type: SourceType) -> Self {
90        self.source_type = source_type;
91        self
92    }
93
94    pub fn with_color(mut self, color: Color32) -> Self {
95        self.color = Some(color);
96        self
97    }
98}
99
100/// A routing output destination
101#[derive(Debug, Clone)]
102pub struct RouteOutput {
103    pub id: String,
104    pub name: String,
105    pub output_type: OutputType,
106    pub enabled: bool,
107    pub resolution: Option<(u32, u32)>,
108}
109
110impl RouteOutput {
111    pub fn new(id: impl Into<String>, name: impl Into<String>) -> Self {
112        Self {
113            id: id.into(),
114            name: name.into(),
115            output_type: OutputType::Display,
116            enabled: true,
117            resolution: None,
118        }
119    }
120
121    pub fn with_type(mut self, output_type: OutputType) -> Self {
122        self.output_type = output_type;
123        self
124    }
125
126    pub fn with_enabled(mut self, enabled: bool) -> Self {
127        self.enabled = enabled;
128        self
129    }
130
131    pub fn with_resolution(mut self, width: u32, height: u32) -> Self {
132        self.resolution = Some((width, height));
133        self
134    }
135}
136
137/// A routing connection
138#[derive(Debug, Clone, PartialEq)]
139pub struct RouteConnection {
140    pub source_id: String,
141    pub output_id: String,
142}
143
144impl RouteConnection {
145    pub fn new(source_id: impl Into<String>, output_id: impl Into<String>) -> Self {
146        Self {
147            source_id: source_id.into(),
148            output_id: output_id.into(),
149        }
150    }
151}
152
153/// Events emitted by OutputRouter
154#[derive(Debug, Clone)]
155pub enum RouterEvent {
156    Connect {
157        source_id: String,
158        output_id: String,
159    },
160    Disconnect {
161        source_id: String,
162        output_id: String,
163    },
164    ToggleOutput(String),
165    SelectSource(String),
166    SelectOutput(String),
167}
168
169/// Output router widget
170pub struct OutputRouter<'a> {
171    sources: &'a [RouteSource],
172    outputs: &'a [RouteOutput],
173    connections: &'a [RouteConnection],
174    selected_source: Option<&'a str>,
175    selected_output: Option<&'a str>,
176    size: Vec2,
177    show_labels: bool,
178    show_resolution: bool,
179    compact: bool,
180}
181
182impl<'a> OutputRouter<'a> {
183    pub fn new(
184        sources: &'a [RouteSource],
185        outputs: &'a [RouteOutput],
186        connections: &'a [RouteConnection],
187    ) -> Self {
188        Self {
189            sources,
190            outputs,
191            connections,
192            selected_source: None,
193            selected_output: None,
194            size: Vec2::new(400.0, 200.0),
195            show_labels: true,
196            show_resolution: true,
197            compact: false,
198        }
199    }
200
201    pub fn selected_source(mut self, id: Option<&'a str>) -> Self {
202        self.selected_source = id;
203        self
204    }
205
206    pub fn selected_output(mut self, id: Option<&'a str>) -> Self {
207        self.selected_output = id;
208        self
209    }
210
211    pub fn size(mut self, width: f32, height: f32) -> Self {
212        self.size = Vec2::new(width, height);
213        self
214    }
215
216    pub fn show_labels(mut self, show: bool) -> Self {
217        self.show_labels = show;
218        self
219    }
220
221    pub fn show_resolution(mut self, show: bool) -> Self {
222        self.show_resolution = show;
223        self
224    }
225
226    pub fn compact(mut self, compact: bool) -> Self {
227        self.compact = compact;
228        self
229    }
230
231    pub fn show_with<Msg>(self, ctx: &mut ViewCtx<'_, Msg>, on_event: impl Fn(RouterEvent) -> Msg) {
232        if let Some(e) = self.show_internal(ctx.ui) {
233            ctx.emit(on_event(e));
234        }
235    }
236
237    pub fn show(self, ui: &mut Ui) -> Option<RouterEvent> {
238        self.show_internal(ui)
239    }
240
241    fn show_internal(self, ui: &mut Ui) -> Option<RouterEvent> {
242        let theme = Theme::current(ui.ctx());
243        let mut event: Option<RouterEvent> = None;
244
245        let (rect, _response) = ui.allocate_exact_size(self.size, Sense::hover());
246
247        if !ui.is_rect_visible(rect) {
248            return None;
249        }
250
251        let padding = theme.spacing_sm;
252        let inner_rect = rect.shrink(padding);
253
254        // Layout: Sources on left, matrix in center, outputs on right
255        let source_width = if self.compact { 60.0 } else { 80.0 };
256        let output_height = if self.compact { 50.0 } else { 70.0 };
257        let node_size = if self.compact { 16.0 } else { 20.0 };
258
259        let matrix_rect = Rect::from_min_max(
260            Pos2::new(inner_rect.min.x + source_width + padding, inner_rect.min.y),
261            Pos2::new(inner_rect.max.x, inner_rect.max.y - output_height - padding),
262        );
263
264        // Collect interactions
265        struct SourceInfo {
266            id: String,
267            rect: Rect,
268            node_pos: Pos2,
269            clicked: bool,
270            hovered: bool,
271        }
272
273        struct OutputInfo {
274            id: String,
275            rect: Rect,
276            node_pos: Pos2,
277            clicked: bool,
278            hovered: bool,
279            toggle_clicked: bool,
280        }
281
282        struct MatrixNodeInfo {
283            source_id: String,
284            output_id: String,
285            pos: Pos2,
286            clicked: bool,
287            hovered: bool,
288            connected: bool,
289        }
290
291        let mut source_infos: Vec<SourceInfo> = Vec::new();
292        let mut output_infos: Vec<OutputInfo> = Vec::new();
293        let mut matrix_infos: Vec<MatrixNodeInfo> = Vec::new();
294
295        // Source interactions
296        let source_spacing = if self.sources.is_empty() {
297            0.0
298        } else {
299            (matrix_rect.height() - node_size) / self.sources.len().max(1) as f32
300        };
301
302        for (i, source) in self.sources.iter().enumerate() {
303            let y = matrix_rect.min.y + node_size / 2.0 + i as f32 * source_spacing;
304            let source_rect = Rect::from_min_size(
305                Pos2::new(inner_rect.min.x, y - node_size),
306                Vec2::new(source_width, node_size * 2.0),
307            );
308            let node_pos = Pos2::new(matrix_rect.min.x, y);
309
310            let resp = ui.allocate_rect(source_rect, Sense::click());
311            source_infos.push(SourceInfo {
312                id: source.id.clone(),
313                rect: source_rect,
314                node_pos,
315                clicked: resp.clicked(),
316                hovered: resp.hovered(),
317            });
318        }
319
320        // Output interactions
321        let output_spacing = if self.outputs.is_empty() {
322            0.0
323        } else {
324            (matrix_rect.width() - node_size) / self.outputs.len().max(1) as f32
325        };
326
327        for (i, output) in self.outputs.iter().enumerate() {
328            let x = matrix_rect.min.x + node_size / 2.0 + i as f32 * output_spacing;
329            let output_rect = Rect::from_min_size(
330                Pos2::new(x - node_size, inner_rect.max.y - output_height),
331                Vec2::new(node_size * 2.0 + 20.0, output_height),
332            );
333            let node_pos = Pos2::new(x, matrix_rect.max.y);
334
335            let resp = ui.allocate_rect(output_rect, Sense::click());
336
337            // Toggle button area
338            let toggle_rect = Rect::from_center_size(
339                Pos2::new(x, inner_rect.max.y - theme.spacing_sm - 8.0),
340                Vec2::splat(16.0),
341            );
342            let toggle_resp = ui.allocate_rect(toggle_rect, Sense::click());
343
344            output_infos.push(OutputInfo {
345                id: output.id.clone(),
346                rect: output_rect,
347                node_pos,
348                clicked: resp.clicked() && !toggle_resp.hovered(),
349                hovered: resp.hovered(),
350                toggle_clicked: toggle_resp.clicked(),
351            });
352        }
353
354        // Matrix node interactions
355        for (si, source) in self.sources.iter().enumerate() {
356            for (oi, output) in self.outputs.iter().enumerate() {
357                let y = matrix_rect.min.y + node_size / 2.0 + si as f32 * source_spacing;
358                let x = matrix_rect.min.x + node_size / 2.0 + oi as f32 * output_spacing;
359                let pos = Pos2::new(x, y);
360
361                let node_rect = Rect::from_center_size(pos, Vec2::splat(node_size + 4.0));
362                let resp = ui.allocate_rect(node_rect, Sense::click());
363
364                let connected = self
365                    .connections
366                    .iter()
367                    .any(|c| c.source_id == source.id && c.output_id == output.id);
368
369                matrix_infos.push(MatrixNodeInfo {
370                    source_id: source.id.clone(),
371                    output_id: output.id.clone(),
372                    pos,
373                    clicked: resp.clicked(),
374                    hovered: resp.hovered(),
375                    connected,
376                });
377            }
378        }
379
380        // Drawing
381        let painter = ui.painter();
382
383        // Background
384        painter.rect_filled(rect, theme.radius_md, theme.bg_secondary);
385
386        // Matrix background
387        painter.rect_filled(matrix_rect, theme.radius_sm, theme.bg_primary);
388
389        // Grid lines
390        let grid_stroke = Stroke::new(0.5, theme.border.gamma_multiply(0.3));
391        for (i, _) in self.sources.iter().enumerate() {
392            let y = matrix_rect.min.y + node_size / 2.0 + i as f32 * source_spacing;
393            painter.line_segment(
394                [
395                    Pos2::new(matrix_rect.min.x, y),
396                    Pos2::new(matrix_rect.max.x, y),
397                ],
398                grid_stroke,
399            );
400        }
401        for (i, _) in self.outputs.iter().enumerate() {
402            let x = matrix_rect.min.x + node_size / 2.0 + i as f32 * output_spacing;
403            painter.line_segment(
404                [
405                    Pos2::new(x, matrix_rect.min.y),
406                    Pos2::new(x, matrix_rect.max.y),
407                ],
408                grid_stroke,
409            );
410        }
411
412        // Draw connection lines
413        for info in matrix_infos.iter().filter(|i| i.connected) {
414            let source_info = source_infos.iter().find(|s| s.id == info.source_id);
415            let output_info = output_infos.iter().find(|o| o.id == info.output_id);
416
417            if let (Some(src), Some(out)) = (source_info, output_info) {
418                // Horizontal line from source to node
419                painter.line_segment(
420                    [src.node_pos, Pos2::new(info.pos.x, src.node_pos.y)],
421                    Stroke::new(2.0, theme.primary.gamma_multiply(0.5)),
422                );
423                // Vertical line from node to output
424                painter.line_segment(
425                    [info.pos, out.node_pos],
426                    Stroke::new(2.0, theme.primary.gamma_multiply(0.5)),
427                );
428            }
429        }
430
431        // Draw sources
432        for (info, source) in source_infos.iter().zip(self.sources.iter()) {
433            let is_selected = self.selected_source == Some(&source.id);
434            let is_hovered = info.hovered;
435
436            let color = source.color.unwrap_or(theme.primary);
437            let bg = if is_selected {
438                color.gamma_multiply(0.3)
439            } else if is_hovered {
440                theme.bg_tertiary
441            } else {
442                Color32::TRANSPARENT
443            };
444
445            painter.rect_filled(info.rect, theme.radius_sm, bg);
446
447            // Source label
448            if self.show_labels {
449                painter.text(
450                    Pos2::new(info.rect.min.x + theme.spacing_xs, info.node_pos.y),
451                    egui::Align2::LEFT_CENTER,
452                    &source.name,
453                    egui::FontId::proportional(theme.font_size_xs),
454                    theme.text_primary,
455                );
456            }
457
458            // Source type indicator
459            painter.text(
460                Pos2::new(
461                    info.rect.min.x + theme.spacing_xs,
462                    info.rect.max.y - theme.spacing_xs,
463                ),
464                egui::Align2::LEFT_BOTTOM,
465                source.source_type.label(),
466                egui::FontId::proportional(theme.font_size_xs * 0.8),
467                theme.text_muted,
468            );
469
470            // Connection node
471            painter.circle_filled(info.node_pos, node_size / 2.0 - 2.0, color);
472            painter.circle_stroke(
473                info.node_pos,
474                node_size / 2.0,
475                Stroke::new(1.0, theme.border),
476            );
477        }
478
479        // Draw outputs
480        for (info, output) in output_infos.iter().zip(self.outputs.iter()) {
481            let is_selected = self.selected_output == Some(&output.id);
482            let is_hovered = info.hovered;
483
484            let enabled_color = if output.enabled {
485                theme.primary
486            } else {
487                theme.text_muted
488            };
489            let bg = if is_selected {
490                enabled_color.gamma_multiply(0.3)
491            } else if is_hovered {
492                theme.bg_tertiary
493            } else {
494                Color32::TRANSPARENT
495            };
496
497            painter.rect_filled(info.rect, theme.radius_sm, bg);
498
499            // Output icon
500            painter.text(
501                Pos2::new(info.node_pos.x, info.rect.min.y + output_height * 0.3),
502                egui::Align2::CENTER_CENTER,
503                output.output_type.icon(),
504                egui::FontId::proportional(theme.font_size_md),
505                if output.enabled {
506                    theme.text_primary
507                } else {
508                    theme.text_muted
509                },
510            );
511
512            // Output label
513            if self.show_labels {
514                painter.text(
515                    Pos2::new(info.node_pos.x, info.rect.min.y + output_height * 0.55),
516                    egui::Align2::CENTER_CENTER,
517                    &output.name,
518                    egui::FontId::proportional(theme.font_size_xs),
519                    theme.text_secondary,
520                );
521            }
522
523            // Resolution
524            if self.show_resolution {
525                if let Some((w, h)) = output.resolution {
526                    let res_text = format!("{}x{}", w, h);
527                    painter.text(
528                        Pos2::new(info.node_pos.x, info.rect.min.y + output_height * 0.75),
529                        egui::Align2::CENTER_CENTER,
530                        &res_text,
531                        egui::FontId::proportional(theme.font_size_xs * 0.8),
532                        theme.text_muted,
533                    );
534                }
535            }
536
537            // Enable/disable toggle
538            let toggle_rect = Rect::from_center_size(
539                Pos2::new(info.node_pos.x, inner_rect.max.y - theme.spacing_sm - 8.0),
540                Vec2::splat(14.0),
541            );
542            let toggle_bg = if output.enabled {
543                theme.state_success
544            } else {
545                theme.bg_tertiary
546            };
547            painter.rect_filled(toggle_rect, 2.0, toggle_bg);
548
549            // Connection node
550            painter.circle_filled(info.node_pos, node_size / 2.0 - 2.0, enabled_color);
551            painter.circle_stroke(
552                info.node_pos,
553                node_size / 2.0,
554                Stroke::new(1.0, theme.border),
555            );
556        }
557
558        // Draw matrix nodes
559        for info in matrix_infos.iter() {
560            let fill = if info.connected {
561                theme.primary
562            } else if info.hovered {
563                theme.primary.gamma_multiply(0.5)
564            } else {
565                theme.bg_tertiary
566            };
567
568            painter.circle_filled(info.pos, node_size / 2.0 - 3.0, fill);
569            if info.connected || info.hovered {
570                painter.circle_stroke(
571                    info.pos,
572                    node_size / 2.0 - 1.0,
573                    Stroke::new(1.5, theme.primary),
574                );
575            }
576        }
577
578        // Border
579        painter.rect_stroke(
580            rect,
581            theme.radius_md,
582            Stroke::new(theme.border_width, theme.border),
583            egui::StrokeKind::Inside,
584        );
585
586        // Process events
587        for info in matrix_infos.iter() {
588            if info.clicked {
589                if info.connected {
590                    event = Some(RouterEvent::Disconnect {
591                        source_id: info.source_id.clone(),
592                        output_id: info.output_id.clone(),
593                    });
594                } else {
595                    event = Some(RouterEvent::Connect {
596                        source_id: info.source_id.clone(),
597                        output_id: info.output_id.clone(),
598                    });
599                }
600                break;
601            }
602        }
603
604        if event.is_none() {
605            for info in output_infos.iter() {
606                if info.toggle_clicked {
607                    event = Some(RouterEvent::ToggleOutput(info.id.clone()));
608                    break;
609                }
610                if info.clicked {
611                    event = Some(RouterEvent::SelectOutput(info.id.clone()));
612                    break;
613                }
614            }
615        }
616
617        if event.is_none() {
618            for info in source_infos.iter() {
619                if info.clicked {
620                    event = Some(RouterEvent::SelectSource(info.id.clone()));
621                    break;
622                }
623            }
624        }
625
626        event
627    }
628}