gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Turns direct edits of the interface's copy of the project into actions.
//!
//! Widgets edit the interface's copy of the project in place, which shows the
//! edit immediately. After each frame, `project_edits` compares that copy with
//! the project the engine last confirmed and describes every difference as an
//! action: one per changed project setting, one per changed scene instance.
//! The engine applies them like any other action, so a user interface never
//! sends the whole project, and an edit only competes with other edits of the
//! same setting or scene instance.
//!
//! The patch is the one part too large to compare every frame, and only the
//! patch editor's Apply changes it in the interface (adopting the engine's
//! project changes the confirmed one alike), so it is compared only on a frame
//! after Apply.

#[cfg(test)]
mod bench;

use std::collections::HashSet;

use gled_model::asset::project::Project;
use gled_protocol::action::{TriggerInputs, UiAction};

/// The actions that turn `confirmed` into `edited`. The patches are compared
/// only if `patch_applied`: the patch editor applied one since the last call.
/// No actions mean equal projects.
pub(crate) fn project_edits(
    confirmed: &Option<Project>,
    edited: &Option<Project>,
    patch_applied: bool,
) -> Vec<UiAction> {
    match (confirmed, edited) {
        (Some(confirmed), Some(edited)) => edits(confirmed, edited, patch_applied),
        (Some(_), None) => vec![UiAction::CloseProject],
        // A project is only ever opened by the engine.
        (None, _) => Vec::new(),
    }
}

fn edits(confirmed: &Project, edited: &Project, patch_applied: bool) -> Vec<UiAction> {
    // Destructured completely, so a new project field does not compile until
    // its edits are handled here.
    let Project {
        palette,
        patch,
        grid_width,
        grid_height,
        grid_highlight,
        scenes_instances_grid,
        channel_overwrites,
        output_routings,
        artnet_config,
        tap_input_events,
        blackout_input_events,
        blackout_hold_input_events,
        half_input_events,
        double_input_events,
        midi_active_mappings,
        main_dimmer,
        audio_input_device,
        artnet_control_config,
        osc_config,
    } = edited;

    let mut actions = Vec::new();
    if *palette != confirmed.palette {
        actions.push(UiAction::SetProjectPalette(Box::new(palette.clone())));
    }
    if patch_applied && *patch != confirmed.patch {
        actions.push(UiAction::SetProjectPatch(Box::new(patch.clone())));
    }
    if (*grid_width, *grid_height) != (confirmed.grid_width, confirmed.grid_height) {
        actions.push(UiAction::SetGridSize(*grid_width, *grid_height));
    }
    if *grid_highlight != confirmed.grid_highlight {
        actions.push(UiAction::SetGridHighlight(*grid_highlight));
    }
    if *channel_overwrites != confirmed.channel_overwrites {
        actions.push(UiAction::SetProjectChannelOverwrites(
            channel_overwrites.clone(),
        ));
    }
    if *output_routings != confirmed.output_routings {
        actions.push(UiAction::SetProjectOutputRoutings(output_routings.clone()));
    }
    if *artnet_config != confirmed.artnet_config {
        actions.push(UiAction::SetProjectArtnetConfig(Box::new(
            artnet_config.clone(),
        )));
    }
    let triggers = TriggerInputs {
        tap: tap_input_events.clone(),
        blackout: blackout_input_events.clone(),
        blackout_hold: blackout_hold_input_events.clone(),
        half: half_input_events.clone(),
        double: double_input_events.clone(),
    };
    if triggers != TriggerInputs::of(confirmed) {
        actions.push(UiAction::SetProjectTriggerInputs(Box::new(triggers)));
    }
    if *midi_active_mappings != confirmed.midi_active_mappings {
        actions.push(UiAction::SetMidiActiveMappings(
            midi_active_mappings.clone(),
        ));
    }
    if *main_dimmer != confirmed.main_dimmer {
        actions.push(UiAction::SetMainDimmer(*main_dimmer));
    }
    if *audio_input_device != confirmed.audio_input_device {
        actions.push(UiAction::SetAudioDevice(audio_input_device.clone()));
    }
    if *artnet_control_config != confirmed.artnet_control_config {
        actions.push(UiAction::SetProjectArtnetControl(*artnet_control_config));
    }
    if *osc_config != confirmed.osc_config {
        actions.push(UiAction::SetProjectOsc(*osc_config));
    }

    let locations: HashSet<_> = scenes_instances_grid
        .keys()
        .chain(confirmed.scenes_instances_grid.keys())
        .copied()
        .collect();
    for location in locations {
        let edited = scenes_instances_grid.get(&location);
        if edited != confirmed.scenes_instances_grid.get(&location) {
            actions.push(UiAction::ReplaceSceneInstance(
                location,
                edited.cloned().map(Box::new),
            ));
        }
    }
    actions
}

#[cfg(test)]
mod tests {
    use super::*;
    use gled_model::{
        asset::scene::{grid::GridLocation, instance::SceneInstance},
        patching::fixture::{Fixture, LayoutPosition},
    };

    #[test]
    fn unchanged_projects_need_no_actions() {
        let project = Some(Project::default());
        assert!(project_edits(&project, &project.clone(), true).is_empty());
    }

    #[test]
    fn each_edit_becomes_one_action() {
        let confirmed = Project::default();
        let mut edited = confirmed.clone();
        edited.main_dimmer = 0.5;
        edited
            .patch
            .add_fixture(Fixture::new("bar"), LayoutPosition::default());
        edited.blackout_hold_input_events.clear();
        let location = GridLocation { row: 1, col: 2 };
        edited.scenes_instances_grid.insert(
            location,
            SceneInstance::from_scene_id(Default::default(), &Default::default()),
        );

        let actions = project_edits(&Some(confirmed), &Some(edited), true);
        assert_eq!(actions.len(), 4, "{actions:?}");
        assert!(
            actions
                .iter()
                .any(|action| matches!(action, UiAction::SetMainDimmer(dimmer) if *dimmer == 0.5))
        );
        assert!(
            actions
                .iter()
                .any(|action| matches!(action, UiAction::SetProjectPatch(_)))
        );
        assert!(actions.iter().any(|action| matches!(action, UiAction::SetProjectTriggerInputs(inputs) if inputs.blackout_hold.is_empty())));
        assert!(actions.iter().any(|action| matches!(action, UiAction::ReplaceSceneInstance(at, Some(_)) if *at == location)));
    }

    /// Only Apply changes the interface's patch, so without it the patch is
    /// not even looked at.
    #[test]
    fn the_patch_is_compared_only_after_apply() {
        let confirmed = Project::default();
        let mut edited = confirmed.clone();
        edited
            .patch
            .add_fixture(Fixture::new("bar"), LayoutPosition::default());
        let (confirmed, edited) = (Some(confirmed), Some(edited));
        assert!(project_edits(&confirmed, &edited, false).is_empty());
        assert!(matches!(
            project_edits(&confirmed, &edited, true).as_slice(),
            [UiAction::SetProjectPatch(_)]
        ));
    }

    #[test]
    fn closing_the_project_is_an_action() {
        let actions = project_edits(&Some(Project::default()), &None, false);
        assert!(matches!(actions.as_slice(), [UiAction::CloseProject]));
    }
}