gled-ui 2.34.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! The edits a click in the rule list asks for. The list draws first and
//! collects them; the editor then applies each as one undo step.

use gled_model::asset::midi_controller::{
    MidiControllerMapping, MidiInputAction, MidiModeHighlight, MidiNamedSceneColorMapping,
    MidiOutputBindingKind, MidiSceneColorMessage, MidiSceneColorMessageMap, MidiTrigger,
    MidiValueSource,
};

use super::rules::{Control, FeedbackRole, Members, Numbering, Numbers, Rule, Slot, edit};

/// How each number of an edited action or source reads: the same for all
/// members, or counting with their `n`.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct SlotNumbering(pub(crate) Vec<(Slot, Numbering)>);

impl Numbers for SlotNumbering {
    fn numbering(&self, slot: Slot) -> Numbering {
        self.0
            .iter()
            .find(|(numbered, _)| *numbered == slot)
            .map_or(Numbering::Same, |(_, numbering)| *numbering)
    }
}

#[derive(Debug, Clone)]
pub(crate) enum Intent {
    SetAction {
        members: Members,
        action: MidiInputAction,
        numbering: SlotNumbering,
    },
    RemoveAction(Members),
    RemoveRules(Members),
    SetWhileHeld {
        members: Members,
        held: bool,
    },
    SetMode {
        members: Members,
        mode: Option<u8>,
    },
    SetTouch {
        members: Members,
        touch: Option<MidiTrigger>,
    },
    SetSource {
        members: Members,
        position: usize,
        source: MidiValueSource,
        numbering: SlotNumbering,
    },
    /// Replaces a feedback output's settings (its message, range or color
    /// mapping) on one rule.
    SetFeedbackKind {
        output: usize,
        kind: MidiOutputBindingKind,
    },
    AddFeedback {
        members: Members,
        role: FeedbackRole,
    },
    RemoveFeedback {
        members: Members,
        position: usize,
    },
    Copy {
        rule: Rule,
        n: i32,
        targets: Vec<(usize, i32)>,
        /// Controls not known yet, by the message they send, with their `n`.
        new_controls: Vec<(MidiTrigger, i32)>,
        numbering: SlotNumbering,
    },
    RenameControl {
        control: usize,
        name: String,
    },
    AddMode(String),
    RenameMode {
        mode: u8,
        name: String,
    },
    SetHighlight {
        mode: u8,
        highlight: Option<MidiModeHighlight>,
    },
    MoveMode {
        from: u8,
        to: u8,
    },
    RemoveMode(u8),
}

/// Applies `intent` to `mapping`; returns what to tell in the status line.
pub(crate) fn apply(
    intent: Intent,
    mapping: &mut MidiControllerMapping,
    controls: &mut Vec<Control>,
) -> Option<String> {
    match intent {
        Intent::SetAction {
            members,
            action,
            numbering,
        } => {
            let notes = edit::set_action(mapping, controls, &members, &action, &numbering);
            (!notes.is_empty()).then(|| notes.join("; "))
        }
        Intent::RemoveAction(members) => {
            edit::remove_action(mapping, &members);
            Some(removed("action", members.rules.len()))
        }
        Intent::RemoveRules(members) => {
            edit::remove_rules(mapping, &members);
            Some(removed("rule", members.rules.len()))
        }
        Intent::SetWhileHeld { members, held } => {
            edit::set_while_held(mapping, controls, &members, held);
            None
        }
        Intent::SetMode { members, mode } => {
            edit::set_mode(mapping, &members, mode);
            None
        }
        Intent::SetTouch { members, touch } => {
            edit::set_touch(mapping, &members, touch);
            None
        }
        Intent::SetSource {
            members,
            position,
            source,
            numbering,
        } => {
            edit::set_source(mapping, controls, &members, position, &source, &numbering);
            None
        }
        Intent::SetFeedbackKind { output, kind } => {
            if let Some(binding) = mapping.output_bindings.get_mut(output) {
                binding.kind = kind;
            }
            None
        }
        Intent::AddFeedback { members, role } => {
            if role == FeedbackRole::PadColor && mapping.color_mappings.is_empty() {
                mapping.color_mappings.push(default_colors());
            }
            let mapping_name = mapping
                .color_mappings
                .first()
                .map(|color| color.name.clone())
                .unwrap_or_default();
            let followed = edit::add_feedback(mapping, controls, &members, role, &mapping_name);
            (!followed).then(|| {
                format!(
                    "The new {} shows the selected scene's opacity; click it to choose what it shows",
                    role.name().to_lowercase()
                )
            })
        }
        Intent::RemoveFeedback { members, position } => {
            edit::remove_feedback(mapping, &members, position);
            None
        }
        Intent::Copy {
            rule,
            n,
            mut targets,
            new_controls,
            numbering,
        } => {
            for (trigger, number) in new_controls {
                let control = super::learning::with_control(controls, &trigger);
                if !targets.iter().any(|(target, _)| *target == control) {
                    targets.push((control, number));
                }
            }
            let count = targets.len();
            let replaced = edit::copy_rule(mapping, controls, &rule, n, &targets, &numbering);
            Some(match replaced {
                0 => format!("Copied to {count} controls"),
                replaced => format!("Copied to {count} controls, replacing {replaced} rules"),
            })
        }
        Intent::RenameControl { control, name } => {
            edit::rename_control(mapping, controls, control, &name);
            None
        }
        Intent::AddMode(name) => {
            edit::add_mode(mapping, &name);
            None
        }
        Intent::RenameMode { mode, name } => {
            edit::rename_mode(mapping, controls, mode, &name);
            None
        }
        Intent::SetHighlight { mode, highlight } => {
            if let Some(entry) = mapping.modes.get_mut(usize::from(mode)) {
                entry.highlight = highlight;
            }
            None
        }
        Intent::MoveMode { from, to } => {
            edit::move_mode(mapping, from, to);
            None
        }
        Intent::RemoveMode(mode) => match edit::remove_mode(mapping, mode) {
            Ok(()) => None,
            Err(in_use) => Some(format!(
                "This mode is still used by {} bindings; move or delete them first",
                in_use.bindings
            )),
        },
    }
}

fn removed(what: &str, count: usize) -> String {
    if count == 1 {
        format!("Removed 1 {what} — Ctrl+Z brings it back")
    } else {
        format!("Removed {count} {what}s — Ctrl+Z brings them back")
    }
}

/// A color mapping to start from: the velocities of an Akai APC40 mkII,
/// bright for a scene that is on or flashed, dim for one that is off.
fn default_colors() -> MidiNamedSceneColorMapping {
    let bright = MidiSceneColorMessageMap::default();
    let value = |value| MidiSceneColorMessage { value };
    let dim = MidiSceneColorMessageMap {
        red: value(7),
        green: value(23),
        blue: value(47),
        white: value(1),
        orange: value(11),
        yellow: value(15),
        purple: value(51),
        pink: value(55),
        black: value(0),
    };
    MidiNamedSceneColorMapping {
        name: "Colors".to_owned(),
        active: bright.clone(),
        inactive: dim,
        flashed: bright,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ui::windows::midi_controllers::rules::{build_rules, controls_of, find_groups};
    use gled_model::asset::midi_controller::{MidiInputBinding, MidiSceneTarget};

    /// Sheet B, task 3: one pad learned on a new controller, its rule
    /// copied to the seven notes after it, makes the top row toggle grid
    /// row 1, columns 1–8, each pad in its scene's color.
    #[test]
    fn a_pad_copied_to_the_next_notes_makes_a_row_of_pads() {
        let mut mapping = MidiControllerMapping {
            input_bindings: vec![MidiInputBinding {
                trigger: MidiTrigger {
                    status: 0x90,
                    data1: 32,
                    match_data1: true,
                },
                action: MidiInputAction::ToggleSceneActive {
                    target: MidiSceneTarget::Grid { row: 0, col: 0 },
                },
                ..Default::default()
            }],
            ..Default::default()
        };
        let mut controls = controls_of(&mapping);
        let rule = build_rules(&mapping, &controls)[0].clone();
        apply(
            Intent::AddFeedback {
                members: Members::single(rule),
                role: FeedbackRole::PadColor,
            },
            &mut mapping,
            &mut controls,
        );
        assert_eq!(mapping.color_mappings.len(), 1);

        let rule = build_rules(&mapping, &controls)[0].clone();
        let new_controls = (33..40)
            .zip(2..)
            .map(|(note, n)| {
                let trigger = MidiTrigger {
                    status: 0x90,
                    data1: note,
                    match_data1: true,
                };
                (trigger, n)
            })
            .collect();
        apply(
            Intent::Copy {
                rule,
                n: 1,
                targets: Vec::new(),
                new_controls,
                numbering: SlotNumbering(vec![
                    (Slot::Row, Numbering::Same),
                    (Slot::Column, Numbering::Counting { offset: 0 }),
                ]),
            },
            &mut mapping,
            &mut controls,
        );

        let controls = controls_of(&mapping);
        let rules = build_rules(&mapping, &controls);
        assert_eq!(rules.len(), 8);
        let last = rules
            .iter()
            .find(|rule| controls[rule.control].trigger.data1 == 39)
            .unwrap_or_else(|| panic!("no rule for note 39"));
        let action = &mapping.input_bindings[last.press.unwrap_or_default()].action;
        assert_eq!(
            *action,
            MidiInputAction::ToggleSceneActive {
                target: MidiSceneTarget::Grid { row: 0, col: 7 }
            }
        );
        let pad = &mapping.output_bindings[last.feedback[0]].kind;
        let MidiOutputBindingKind::SceneColorValue(color) = pad else {
            panic!("no pad color: {pad:?}");
        };
        assert_eq!(
            color.source,
            gled_model::asset::midi_controller::MidiColorSource::SceneColor {
                target: MidiSceneTarget::Grid { row: 0, col: 7 }
            }
        );
        assert_eq!(color.data1, 39);
        assert_eq!(find_groups(&mapping, &controls, &rules).len(), 1);
    }
}