glitcher_core/
ui_dsl.rs

1//! UI DSL - Declarative UI System for Glitcher
2//!
3//! "UI as Data, Not Code"
4//!
5//! MODs define UI structure (What), Host renders it (How).
6//! This provides:
7//! - Unified look & feel across all MODs
8//! - Automatic LFO/MIDI assignment for any parameter
9//! - Theme switching affects all MODs at once
10//! - Lightweight WASM (no UI code in modules)
11
12use serde::{Deserialize, Serialize};
13use serde_json;
14
15/// UI Component - the AST of the UI DSL
16///
17/// MODs return this structure to define their UI.
18/// Host interprets and renders using egui.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub enum UiComponent {
21    // ==========================================================================
22    // Layout Containers
23    // ==========================================================================
24    /// Grouped section with label
25    Group {
26        label: String,
27        children: Vec<UiComponent>,
28    },
29
30    /// Horizontal row of components
31    Row { children: Vec<UiComponent> },
32
33    /// Vertical column of components
34    Column { children: Vec<UiComponent> },
35
36    /// Tabbed sections
37    Tabs { tabs: Vec<UiTab> },
38
39    // ==========================================================================
40    // Basic Controls
41    // ==========================================================================
42    /// Rotary knob (best for VJ performance)
43    Knob {
44        label: String,
45        param_id: u32,
46        min: f32,
47        max: f32,
48        #[serde(default)]
49        logarithmic: bool,
50    },
51
52    /// Horizontal slider
53    Slider {
54        label: String,
55        param_id: u32,
56        min: f32,
57        max: f32,
58    },
59
60    /// On/off toggle
61    Toggle { label: String, param_id: u32 },
62
63    /// Dropdown selection
64    Dropdown {
65        label: String,
66        param_id: u32,
67        options: Vec<String>,
68    },
69
70    /// Button group (radio button style for VJ)
71    ButtonGroup {
72        label: String,
73        param_id: u32,
74        options: Vec<String>,
75    },
76
77    /// Color picker (RGB) - for 3-param use cases
78    ColorPicker {
79        label: String,
80        r_param_id: u32,
81        g_param_id: u32,
82        b_param_id: u32,
83    },
84
85    /// Hue slider (single param) - for WIT ColorPicker (0.0 = red, 1.0 = red wrap)
86    HueSlider { label: String, param_id: u32 },
87
88    // ==========================================================================
89    // VJ Special Widgets
90    // ==========================================================================
91    /// 2D XY pad controller
92    XYPad {
93        label: String,
94        x_param_id: u32,
95        y_param_id: u32,
96    },
97
98    /// Bezier curve editor
99    Curves { label: String, param_id: u32 },
100
101    /// Audio waveform/spectrum visualizer
102    AudioScope { label: String, source: AudioSource },
103
104    /// Beat-synced trigger button
105    BeatButton {
106        label: String,
107        trigger_id: u32,
108        division: NoteDivision,
109    },
110
111    /// Action button (one-shot trigger via Action System)
112    ActionButton { label: String, action_id: String },
113
114    /// Trigger button - param-based one-shot (sends 1.0 for one frame, auto-resets to 0.0)
115    TriggerButton { label: String, param_id: u32 },
116
117    /// Modulation source slot (for patching)
118    ModulationSlot { label: String, target_param_id: u32 },
119
120    /// Control signal preview (waveform display for LFO, envelope, etc.)
121    ControlPreview {
122        label: String,
123        port_name: String,
124        /// History buffer for waveform display (values 0.0-1.0)
125        #[serde(default)]
126        history_length: u32,
127    },
128
129    /// Control target selector for ControlOutput nodes (LFO, etc.)
130    ///
131    /// Displays a dropdown to select which node/parameter this control output
132    /// should modulate. The actual connection is managed by the host.
133    ControlTargetSelector {
134        label: String,
135        /// The control output port name (e.g., "output")
136        port_name: String,
137    },
138
139    // ==========================================================================
140    // Display Only
141    // ==========================================================================
142    /// Static label text
143    Label { text: String },
144
145    /// Separator line
146    Separator,
147
148    /// Spacer
149    Spacer { height: f32 },
150
151    // ==========================================================================
152    // Custom / Extension
153    // ==========================================================================
154    /// Custom widget rendered by host
155    ///
156    /// For complex widgets that can't be expressed in DSL.
157    /// Host looks up a custom renderer by widget_id.
158    Custom {
159        widget_id: String,
160        #[serde(default)]
161        props: std::collections::HashMap<String, String>,
162    },
163}
164
165/// Tab definition for Tabs component
166#[derive(Debug, Clone, Serialize, Deserialize)]
167pub struct UiTab {
168    pub label: String,
169    pub children: Vec<UiComponent>,
170}
171
172/// Audio source for AudioScope widget
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174pub enum AudioSource {
175    /// Full spectrum
176    Master,
177    /// Low frequencies (bass)
178    Low,
179    /// Mid frequencies
180    Mid,
181    /// High frequencies (treble)
182    High,
183}
184
185/// Note division for BeatButton (musical note values)
186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
187pub enum NoteDivision {
188    /// Whole note (1 bar)
189    Whole,
190    /// Half note
191    Half,
192    /// Quarter note
193    #[default]
194    Quarter,
195    /// Eighth note
196    Eighth,
197    /// Sixteenth note
198    Sixteenth,
199}
200
201// =============================================================================
202// Builder helpers for cleaner MOD code
203// =============================================================================
204
205impl UiComponent {
206    /// Create a group
207    pub fn group(label: impl Into<String>, children: Vec<UiComponent>) -> Self {
208        Self::Group {
209            label: label.into(),
210            children,
211        }
212    }
213
214    /// Create a row
215    pub fn row(children: Vec<UiComponent>) -> Self {
216        Self::Row { children }
217    }
218
219    /// Create a column
220    pub fn column(children: Vec<UiComponent>) -> Self {
221        Self::Column { children }
222    }
223
224    /// Create a knob
225    pub fn knob(label: impl Into<String>, param_id: u32, min: f32, max: f32) -> Self {
226        Self::Knob {
227            label: label.into(),
228            param_id,
229            min,
230            max,
231            logarithmic: false,
232        }
233    }
234
235    /// Create a logarithmic knob
236    pub fn knob_log(label: impl Into<String>, param_id: u32, min: f32, max: f32) -> Self {
237        Self::Knob {
238            label: label.into(),
239            param_id,
240            min,
241            max,
242            logarithmic: true,
243        }
244    }
245
246    /// Create a slider
247    pub fn slider(label: impl Into<String>, param_id: u32, min: f32, max: f32) -> Self {
248        Self::Slider {
249            label: label.into(),
250            param_id,
251            min,
252            max,
253        }
254    }
255
256    /// Create a toggle
257    pub fn toggle(label: impl Into<String>, param_id: u32) -> Self {
258        Self::Toggle {
259            label: label.into(),
260            param_id,
261        }
262    }
263
264    /// Create a dropdown
265    pub fn dropdown(label: impl Into<String>, param_id: u32, options: Vec<String>) -> Self {
266        Self::Dropdown {
267            label: label.into(),
268            param_id,
269            options,
270        }
271    }
272
273    /// Create a button group
274    pub fn button_group(label: impl Into<String>, param_id: u32, options: Vec<String>) -> Self {
275        Self::ButtonGroup {
276            label: label.into(),
277            param_id,
278            options,
279        }
280    }
281
282    /// Create an XY pad
283    pub fn xy_pad(label: impl Into<String>, x_param_id: u32, y_param_id: u32) -> Self {
284        Self::XYPad {
285            label: label.into(),
286            x_param_id,
287            y_param_id,
288        }
289    }
290
291    /// Create an audio scope
292    pub fn audio_scope(label: impl Into<String>, source: AudioSource) -> Self {
293        Self::AudioScope {
294            label: label.into(),
295            source,
296        }
297    }
298
299    /// Create a beat button
300    pub fn beat_button(label: impl Into<String>, trigger_id: u32, division: NoteDivision) -> Self {
301        Self::BeatButton {
302            label: label.into(),
303            trigger_id,
304            division,
305        }
306    }
307
308    /// Create an action button (one-shot trigger via Action System)
309    pub fn action_button(label: impl Into<String>, action_id: impl Into<String>) -> Self {
310        Self::ActionButton {
311            label: label.into(),
312            action_id: action_id.into(),
313        }
314    }
315
316    /// Create a trigger button (param-based one-shot, auto-resets to 0.0)
317    pub fn trigger_button(label: impl Into<String>, param_id: u32) -> Self {
318        Self::TriggerButton {
319            label: label.into(),
320            param_id,
321        }
322    }
323
324    /// Create a modulation slot
325    pub fn mod_slot(label: impl Into<String>, target_param_id: u32) -> Self {
326        Self::ModulationSlot {
327            label: label.into(),
328            target_param_id,
329        }
330    }
331
332    /// Create a control preview (waveform display for control signals)
333    pub fn control_preview(
334        label: impl Into<String>,
335        port_name: impl Into<String>,
336        history_length: u32,
337    ) -> Self {
338        Self::ControlPreview {
339            label: label.into(),
340            port_name: port_name.into(),
341            history_length,
342        }
343    }
344
345    /// Create a control target selector (for ControlOutput nodes like LFO)
346    pub fn control_target_selector(label: impl Into<String>, port_name: impl Into<String>) -> Self {
347        Self::ControlTargetSelector {
348            label: label.into(),
349            port_name: port_name.into(),
350        }
351    }
352
353    /// Create a label
354    pub fn label(text: impl Into<String>) -> Self {
355        Self::Label { text: text.into() }
356    }
357
358    /// Create a separator
359    pub fn separator() -> Self {
360        Self::Separator
361    }
362
363    /// Create a spacer
364    pub fn spacer(height: f32) -> Self {
365        Self::Spacer { height }
366    }
367
368    /// Create a custom widget
369    pub fn custom(widget_id: impl Into<String>) -> Self {
370        Self::Custom {
371            widget_id: widget_id.into(),
372            props: std::collections::HashMap::new(),
373        }
374    }
375
376    /// Create a custom widget with properties
377    pub fn custom_with_props(
378        widget_id: impl Into<String>,
379        props: std::collections::HashMap<String, String>,
380    ) -> Self {
381        Self::Custom {
382            widget_id: widget_id.into(),
383            props,
384        }
385    }
386}
387
388// =============================================================================
389// Layout Configuration (saveable/loadable)
390// =============================================================================
391
392/// Layout configuration for control panel
393///
394/// Supports saving/loading layouts and future 2D positioning.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct LayoutConfig {
397    /// Layout format version
398    pub version: u32,
399
400    /// Layout name for display
401    pub name: String,
402
403    /// Panel sections with optional 2D positioning
404    pub sections: Vec<SectionConfig>,
405
406    /// MOD UI slots (where MOD UIs are inserted)
407    #[serde(default)]
408    pub mod_slots: Vec<ModSlotConfig>,
409}
410
411/// Section configuration with optional 2D positioning
412#[derive(Debug, Clone, Serialize, Deserialize)]
413pub struct SectionConfig {
414    /// Widget ID (e.g., "header", "effects", "midi")
415    pub widget_id: String,
416
417    /// Whether this section is visible
418    #[serde(default = "default_true")]
419    pub visible: bool,
420
421    /// Optional 2D position (for future grid/absolute layout)
422    #[serde(default)]
423    pub position: Option<LayoutPosition>,
424
425    /// Optional size hints
426    #[serde(default)]
427    pub size: Option<LayoutSize>,
428
429    /// Section-specific properties
430    #[serde(default)]
431    pub props: std::collections::HashMap<String, String>,
432}
433
434/// MOD UI slot configuration
435#[derive(Debug, Clone, Serialize, Deserialize)]
436pub struct ModSlotConfig {
437    /// Slot identifier
438    pub slot_id: String,
439
440    /// Position in the layout (after which section)
441    pub after_section: Option<String>,
442
443    /// MOD ID to display in this slot (None = empty slot)
444    pub mod_id: Option<String>,
445
446    /// 2D position (for future)
447    #[serde(default)]
448    pub position: Option<LayoutPosition>,
449
450    /// Size hints
451    #[serde(default)]
452    pub size: Option<LayoutSize>,
453}
454
455/// 2D position for grid/absolute layout
456#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
457pub struct LayoutPosition {
458    /// X position (grid column or pixels)
459    pub x: f32,
460    /// Y position (grid row or pixels)
461    pub y: f32,
462}
463
464/// Size hints for layout
465#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
466pub struct LayoutSize {
467    /// Width (grid units or pixels, 0 = auto)
468    pub width: f32,
469    /// Height (grid units or pixels, 0 = auto)
470    pub height: f32,
471}
472
473fn default_true() -> bool {
474    true
475}
476
477impl Default for LayoutConfig {
478    fn default() -> Self {
479        Self {
480            version: 1,
481            name: "Default".to_string(),
482            sections: vec![
483                SectionConfig::new("header"),
484                SectionConfig::new("audio_status"),
485                SectionConfig::new("frequency_bars"),
486                SectionConfig::new("effects"),
487                SectionConfig::new("textures"),
488                SectionConfig::new("recording"),
489                SectionConfig::new("modulation"),
490                SectionConfig::new("blend_mode"),
491                SectionConfig::new("color_scheme"),
492                SectionConfig::new("simulation"),
493                SectionConfig::new("midi"),
494                SectionConfig::new("footer"),
495            ],
496            mod_slots: vec![ModSlotConfig {
497                slot_id: "mod_slot_1".to_string(),
498                after_section: Some("simulation".to_string()),
499                mod_id: None,
500                position: None,
501                size: None,
502            }],
503        }
504    }
505}
506
507impl SectionConfig {
508    pub fn new(widget_id: impl Into<String>) -> Self {
509        Self {
510            widget_id: widget_id.into(),
511            visible: true,
512            position: None,
513            size: None,
514            props: std::collections::HashMap::new(),
515        }
516    }
517
518    pub fn with_visibility(mut self, visible: bool) -> Self {
519        self.visible = visible;
520        self
521    }
522
523    pub fn with_position(mut self, x: f32, y: f32) -> Self {
524        self.position = Some(LayoutPosition { x, y });
525        self
526    }
527
528    pub fn with_size(mut self, width: f32, height: f32) -> Self {
529        self.size = Some(LayoutSize { width, height });
530        self
531    }
532}
533
534impl LayoutConfig {
535    /// Build UiComponent tree from this config
536    pub fn to_ui_component(&self) -> UiComponent {
537        let mut children: Vec<UiComponent> = Vec::new();
538
539        for (i, section) in self.sections.iter().enumerate() {
540            if !section.visible {
541                continue;
542            }
543
544            // Add spacer between sections (except first)
545            if i > 0 {
546                children.push(UiComponent::spacer(10.0));
547            }
548
549            // Check if any MOD slot should be inserted after this section
550            for mod_slot in &self.mod_slots {
551                if mod_slot.after_section.as_deref() == Some(&section.widget_id) {
552                    if let Some(mod_id) = &mod_slot.mod_id {
553                        // Insert MOD UI placeholder
554                        children.push(UiComponent::spacer(10.0));
555                        children.push(UiComponent::custom_with_props(
556                            "mod_ui",
557                            [("mod_id".to_string(), mod_id.clone())]
558                                .into_iter()
559                                .collect(),
560                        ));
561                    }
562                }
563            }
564
565            // Add section
566            children.push(UiComponent::custom_with_props(
567                section.widget_id.clone(),
568                section.props.clone(),
569            ));
570        }
571
572        UiComponent::column(children)
573    }
574
575    /// Save layout to JSON string
576    pub fn to_json(&self) -> Result<String, serde_json::Error> {
577        serde_json::to_string_pretty(self)
578    }
579
580    /// Load layout from JSON string
581    pub fn from_json(json: &str) -> Result<Self, serde_json::Error> {
582        serde_json::from_str(json)
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    #[test]
591    fn test_ui_component_serialization() {
592        let ui = UiComponent::group(
593            "Test",
594            vec![
595                UiComponent::knob("Intensity", 0, 0.0, 1.0),
596                UiComponent::toggle("Enable", 1),
597            ],
598        );
599
600        let json = serde_json::to_string_pretty(&ui).unwrap();
601        println!("{}", json);
602
603        let parsed: UiComponent = serde_json::from_str(&json).unwrap();
604        match parsed {
605            UiComponent::Group { label, children } => {
606                assert_eq!(label, "Test");
607                assert_eq!(children.len(), 2);
608            }
609            _ => panic!("Expected Group"),
610        }
611    }
612
613    #[test]
614    fn test_layout_config_serialization() {
615        let config = LayoutConfig::default();
616        let json = config.to_json().unwrap();
617        println!("Default layout:\n{}", json);
618
619        let parsed = LayoutConfig::from_json(&json).unwrap();
620        assert_eq!(parsed.version, 1);
621        assert_eq!(parsed.name, "Default");
622        assert_eq!(parsed.sections.len(), 12);
623        assert_eq!(parsed.mod_slots.len(), 1);
624    }
625
626    #[test]
627    fn test_layout_config_to_ui_component() {
628        let mut config = LayoutConfig::default();
629        // Hide some sections
630        config.sections[2].visible = false; // frequency_bars
631        config.sections[5].visible = false; // recording
632
633        let ui = config.to_ui_component();
634        match ui {
635            UiComponent::Column { children } => {
636                // Should have fewer children due to hidden sections
637                assert!(children.len() < 24); // Less than 12 sections * 2 (section + spacer)
638            }
639            _ => panic!("Expected Column"),
640        }
641    }
642
643    #[test]
644    fn test_layout_config_with_mod_slot() {
645        let mut config = LayoutConfig::default();
646        config.mod_slots[0].mod_id = Some("chromatic_aberration".to_string());
647
648        let ui = config.to_ui_component();
649        let json = serde_json::to_string_pretty(&ui).unwrap();
650        println!("Layout with MOD:\n{}", json);
651
652        // Should contain mod_ui custom widget
653        assert!(json.contains("mod_ui"));
654        assert!(json.contains("chromatic_aberration"));
655    }
656}