gled-model 2.31.0

The data model of gled: projects, scenes, effects, animations, curves and assets
Documentation
mod output;

pub use output::{
    MidiColorSource, MidiNamedSceneColorMapping, MidiOutputBinding, MidiOutputBindingKind,
    MidiOutputBindingSelector, MidiSceneColorMessage, MidiSceneColorMessageMap,
    MidiSceneColorValueOutput, MidiValueOutput, MidiValueSource, MidiValueSourceKind,
};

use super::AssetTrait;
use serde::{Deserialize, Serialize};
use strum::{Display, EnumDiscriminants, EnumIter};

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
pub struct MidiController {
    pub mapping: MidiControllerMapping,
}

impl AssetTrait for MidiController {
    const DIR_NAME: &'static str = "midi_controllers";
    const NAME: &'static str = "MIDI Controller";
    const SHOW_NAME_IF_SELECTED: bool = true;
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default)]
pub struct MidiControllerMapping {
    pub input_bindings: Vec<MidiInputBinding>,
    pub output_bindings: Vec<MidiOutputBinding>,
    pub color_mappings: Vec<MidiNamedSceneColorMapping>,
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
pub struct MidiInputBinding {
    pub name: String,
    pub trigger: MidiTrigger,
    pub action: MidiInputAction,
}

impl Default for MidiInputBinding {
    fn default() -> Self {
        Self {
            name: "Input".to_owned(),
            trigger: MidiTrigger::default(),
            action: MidiInputAction::Tap,
        }
    }
}

#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone)]
pub struct MidiTrigger {
    pub status: u8,
    pub data1: u8,
    pub match_data1: bool,
}

impl Default for MidiTrigger {
    fn default() -> Self {
        Self {
            status: 0,
            data1: 0,
            match_data1: true,
        }
    }
}

/// `MidiInputActionKind` is the action without its settings; it reads as
/// the label the controller window shows.
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, EnumDiscriminants)]
#[strum_discriminants(
    name(MidiInputActionKind),
    derive(Display, EnumIter),
    strum(serialize_all = "title_case")
)]
pub enum MidiInputAction {
    Tap,
    SetMainDimmer,
    SetBlackout,
    SetSpeedAdd,
    SetSpeedMultiply,
    #[strum_discriminants(strum(serialize = "Select Scene (Distributed 0-127)"))]
    SelectSceneDistributed,
    SelectScene {
        target: MidiSceneTarget,
    },
    ToggleSceneActive {
        target: MidiSceneTarget,
    },
    SetSceneActive {
        target: MidiSceneTarget,
    },
    FlashScene {
        target: MidiSceneTarget,
    },
    SetSceneOpacity {
        target: MidiSceneTarget,
    },
    SetSceneInputDimmer {
        target: MidiSceneTarget,
    },
    SetSceneBeatOffset {
        target: MidiSceneTarget,
    },
    SetSceneIgnoreMainDimmer {
        target: MidiSceneTarget,
    },
    SetSceneSetOffsetOnFlash {
        target: MidiSceneTarget,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Setting (n,n) f32"))]
    SetSceneEffectSettingF32 {
        target: MidiSceneTarget,
        effect_index: u8,
        setting_index: u8,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Opacity"))]
    SetSceneEffectOpacity {
        target: MidiSceneTarget,
        effect_index: u8,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Color Shift"))]
    SetSceneEffectColorShift {
        target: MidiSceneTarget,
        effect_index: u8,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Beat Progression"))]
    SetSceneEffectBeatProgression {
        target: MidiSceneTarget,
        effect_index: u8,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Beat Offset"))]
    SetSceneEffectBeatOffset {
        target: MidiSceneTarget,
        effect_index: u8,
    },
    #[strum_discriminants(strum(serialize = "Set Effect Speed Exponent"))]
    SetSceneEffectSpeedExponent {
        target: MidiSceneTarget,
        effect_index: u8,
    },
    /// Picks the effect's layout: 0 inherits the tile's layout, 1 is the
    /// topology, 2… the layouts in stack order (`Patch::layout_refs`). Was the
    /// effect's group index before the patch.
    #[serde(alias = "SetSceneEffectGroupIndex")]
    #[strum_discriminants(strum(serialize = "Set Effect Layout"))]
    SetSceneEffectLayout {
        target: MidiSceneTarget,
        effect_index: u8,
    },
}

/// `MidiSceneTargetKind` is the target without its position; it reads as
/// the label the controller window shows.
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq, Clone, Default, EnumDiscriminants)]
#[strum_discriminants(
    name(MidiSceneTargetKind),
    derive(Display, EnumIter),
    strum(serialize_all = "title_case")
)]
pub enum MidiSceneTarget {
    #[default]
    #[strum_discriminants(strum(serialize = "Selected Scene"))]
    Selected,
    #[strum_discriminants(strum(serialize = "Quick Scene"))]
    Quick { index: u8 },
    #[strum_discriminants(strum(serialize = "Grid Scene"))]
    Grid { row: u8, col: u8 },
}
/// A port name without the number some systems append, such as
/// `"APC40 [2]"` → `"APC40"`, so a controller keeps its mapping when it is
/// plugged in again.
pub fn normalize_controller_key(port_name: &str) -> String {
    let trimmed = port_name.trim();
    if let Some(prefix) = trimmed.strip_suffix(']')
        && let Some((base, suffix)) = prefix.rsplit_once(" [")
        && !base.is_empty()
        && !suffix.is_empty()
        && suffix.chars().all(|ch| ch.is_ascii_digit())
    {
        return base.to_owned();
    }

    trimmed.to_owned()
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Groups became layouts; a controller saved with the group names must
    /// still load, and then means the effect's layout.
    #[test]
    fn group_index_bindings_saved_before_the_patch_load_as_layout_bindings() {
        let action: MidiInputAction = serde_json::from_str(
            r#"{"SetSceneEffectGroupIndex": {"target": "Selected", "effect_index": 1}}"#,
        )
        .expect("an old group index action must load");
        assert_eq!(
            action,
            MidiInputAction::SetSceneEffectLayout {
                target: MidiSceneTarget::Selected,
                effect_index: 1,
            }
        );

        let source: MidiValueSource = serde_json::from_str(
            r#"{"SceneEffectGroupIndex": {"target": "Selected", "effect_index": 1}}"#,
        )
        .expect("an old group index feedback must load");
        assert_eq!(
            source,
            MidiValueSource::SceneEffectLayout {
                target: MidiSceneTarget::Selected,
                effect_index: 1,
            }
        );
    }

    /// The controller window lists these labels; the generated kinds must
    /// read exactly as the hand-written labels did.
    #[test]
    fn kinds_read_as_the_controller_window_labels() {
        use strum::IntoEnumIterator;
        let actions: Vec<String> = MidiInputActionKind::iter().map(|k| k.to_string()).collect();
        assert_eq!(
            actions,
            [
                "Tap",
                "Set Main Dimmer",
                "Set Blackout",
                "Set Speed Add",
                "Set Speed Multiply",
                "Select Scene (Distributed 0-127)",
                "Select Scene",
                "Toggle Scene Active",
                "Set Scene Active",
                "Flash Scene",
                "Set Scene Opacity",
                "Set Scene Input Dimmer",
                "Set Scene Beat Offset",
                "Set Scene Ignore Main Dimmer",
                "Set Scene Set Offset On Flash",
                "Set Effect Setting (n,n) f32",
                "Set Effect Opacity",
                "Set Effect Color Shift",
                "Set Effect Beat Progression",
                "Set Effect Beat Offset",
                "Set Effect Speed Exponent",
                "Set Effect Layout",
            ]
        );
        let sources: Vec<String> = MidiValueSourceKind::iter().map(|k| k.to_string()).collect();
        assert_eq!(
            sources,
            [
                "Selected Scene Opacity",
                "Selected Scene (Distributed 0-127)",
                "Selected Scene Input Dimmer",
                "Selected Scene Beat Offset",
                "Selected Scene Ignore Main Dimmer",
                "Selected Scene Set Offset On Flash",
                "Main Dimmer",
                "Beat Flank",
                "Blackout",
                "Scene Opacity",
                "Scene Input Dimmer",
                "Scene Beat Offset",
                "Scene Ignore Main Dimmer",
                "Scene Set Offset On Flash",
                "Scene Active",
                "Scene Flashed",
                "Scene Effect Setting (n,n) f32",
                "Effect Opacity",
                "Effect Color Shift",
                "Effect Beat Progression",
                "Effect Beat Offset",
                "Effect Speed Exponent",
                "Effect Layout",
            ]
        );
        let targets: Vec<String> = MidiSceneTargetKind::iter().map(|k| k.to_string()).collect();
        assert_eq!(targets, ["Selected Scene", "Quick Scene", "Grid Scene"]);
        let outputs: Vec<String> = MidiOutputBindingSelector::iter()
            .map(|k| k.to_string())
            .collect();
        assert_eq!(outputs, ["Single Value", "Scene Color Mapping"]);
        assert_eq!(MidiColorSource::default().to_string(), "Scene Color");
    }
}