use gled_model::asset::midi_controller::{
MidiEffectParameter, MidiEffectTarget, MidiInputAction, MidiInputActionKind, MidiMode,
MidiSceneTarget, MidiValueSource, MidiValueSourceKind,
};
use super::catalog::action_info;
use super::groups::Numbering;
use super::numbers::Slot;
use super::sources::source_info;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TokenKind {
Main,
Target,
Effect,
Setting,
Animation,
Mode,
Encoder,
Parameter,
Beat,
Blackout,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Piece {
Text(String),
Token { kind: TokenKind, text: String },
}
pub(crate) trait Numbers {
fn numbering(&self, slot: Slot) -> Numbering;
}
pub(crate) struct Plain;
impl Numbers for Plain {
fn numbering(&self, _slot: Slot) -> Numbering {
Numbering::Same
}
}
fn number(numbers: &dyn Numbers, slot: Slot, value: u8) -> String {
match numbers.numbering(slot) {
Numbering::Same => (u16::from(value) + 1).to_string(),
Numbering::Counting { offset: 0 } => "n".to_owned(),
Numbering::Counting { offset } if offset > 0 => format!("n+{offset}"),
Numbering::Counting { offset } => format!("n−{}", -offset),
}
}
pub(crate) fn action_sentence(
action: &MidiInputAction,
modes: &[MidiMode],
numbers: &dyn Numbers,
) -> Vec<Piece> {
let info = action_info(MidiInputActionKind::from(action));
fill(info.phrase, |kind| match kind {
TokenKind::Target => action_target(action).map(|target| target_text(target, numbers)),
TokenKind::Effect => action_effect(action).map(|effect| effect_text(effect, numbers)),
TokenKind::Setting => match action {
MidiInputAction::SetSceneEffectSettingF32 { setting_index, .. } => Some(format!(
"setting {}",
number(numbers, Slot::Setting, *setting_index)
)),
_ => None,
},
TokenKind::Animation => match action {
MidiInputAction::SelectSceneEffect { index } => Some(format!(
"animation {}",
number(numbers, Slot::Animation, *index)
)),
_ => None,
},
TokenKind::Mode => match action {
MidiInputAction::SelectMode { mode } => Some(mode_name(modes, *mode)),
_ => None,
},
TokenKind::Encoder => action_encoder_steps(action).map(|steps| {
if steps == 1 {
"1 click per step".to_owned()
} else {
format!("{steps} clicks per step")
}
}),
_ => None,
})
}
pub(crate) fn source_sentence(
source: &MidiValueSource,
modes: &[MidiMode],
numbers: &dyn Numbers,
) -> Vec<Piece> {
let info = source_info(MidiValueSourceKind::from(source));
fill(info.phrase, |kind| match (kind, source) {
(TokenKind::Target, _) => source_target(source).map(|target| target_text(target, numbers)),
(TokenKind::Effect, _) => source_effect(source).map(|effect| effect_text(effect, numbers)),
(TokenKind::Setting, MidiValueSource::SceneEffectSettingF32 { setting_index, .. }) => Some(
format!("setting {}", number(numbers, Slot::Setting, *setting_index)),
),
(TokenKind::Animation, MidiValueSource::EffectSelected { index }) => Some(format!(
"animation {}",
number(numbers, Slot::Animation, *index)
)),
(TokenKind::Parameter, MidiValueSource::SelectedSceneEffectLive { parameter, .. }) => {
Some(parameter_text(*parameter, numbers))
}
(TokenKind::Mode, MidiValueSource::ModeActive { mode }) => Some(mode_name(modes, *mode)),
(
TokenKind::Beat,
MidiValueSource::BeatFlankPulse {
start_beat,
end_beat,
blackout_blink_value,
every_beat,
},
) => Some(beat_text(
*start_beat,
*end_beat,
*every_beat,
blackout_blink_value.is_some(),
)),
(TokenKind::Blackout, MidiValueSource::Blackout { inverted, blink }) => {
let lit = if *inverted {
"(lit when not in blackout)"
} else {
"(lit during blackout)"
};
Some(if *blink {
format!("{lit}, blinking")
} else {
lit.to_owned()
})
}
_ => None,
})
}
pub(crate) fn text(pieces: &[Piece]) -> String {
pieces
.iter()
.map(|piece| match piece {
Piece::Text(text) | Piece::Token { text, .. } => text.as_str(),
})
.collect()
}
fn fill(phrase: &str, value: impl Fn(TokenKind) -> Option<String>) -> Vec<Piece> {
let mut pieces = Vec::new();
let mut rest = phrase;
while let Some(start) = rest.find('{') {
if start > 0 {
pieces.push(Piece::Text(rest[..start].to_owned()));
}
let Some(length) = rest[start..].find('}') else {
break;
};
let placeholder = &rest[start + 1..start + length];
rest = &rest[start + length + 1..];
if let Some(main) = placeholder.strip_prefix('@') {
pieces.push(Piece::Token {
kind: TokenKind::Main,
text: main.to_owned(),
});
continue;
}
let kind = match placeholder {
"target" => TokenKind::Target,
"effect" => TokenKind::Effect,
"setting" => TokenKind::Setting,
"animation" => TokenKind::Animation,
"mode" => TokenKind::Mode,
"encoder" => TokenKind::Encoder,
"parameter" => TokenKind::Parameter,
"beat" => TokenKind::Beat,
"blackout" => TokenKind::Blackout,
_ => continue,
};
if let Some(text) = value(kind) {
pieces.push(Piece::Token { kind, text });
}
}
if !rest.is_empty() {
pieces.push(Piece::Text(rest.to_owned()));
}
pieces
}
pub(crate) fn target_text(target: &MidiSceneTarget, numbers: &dyn Numbers) -> String {
match target {
MidiSceneTarget::Selected => "the selected scene".to_owned(),
MidiSceneTarget::Row { col } => {
format!(
"scene {} in the selected row",
number(numbers, Slot::Column, *col)
)
}
MidiSceneTarget::Column { row } => format!(
"the scene in row {} of the selected column",
number(numbers, Slot::Row, *row)
),
MidiSceneTarget::Grid { row, col } => format!(
"grid scene row {}, column {}",
number(numbers, Slot::Row, *row),
number(numbers, Slot::Column, *col)
),
MidiSceneTarget::Quick { index } => {
format!("quick scene {}", number(numbers, Slot::Quick, *index))
}
}
}
fn effect_text(effect: &MidiEffectTarget, numbers: &dyn Numbers) -> String {
match effect {
MidiEffectTarget::Selected => "the selected animation".to_owned(),
MidiEffectTarget::Index { index } => {
format!("animation {}", number(numbers, Slot::Effect, *index))
}
}
}
fn parameter_text(parameter: MidiEffectParameter, numbers: &dyn Numbers) -> String {
match parameter {
MidiEffectParameter::Opacity => "opacity".to_owned(),
MidiEffectParameter::BeatProgression => "beat progression".to_owned(),
MidiEffectParameter::ColorShift => "color shift".to_owned(),
MidiEffectParameter::BeatOffset => "beat offset".to_owned(),
setting => {
let index = (0..6)
.find(|&index| MidiEffectParameter::setting(index) == Some(setting))
.unwrap_or(0);
format!("setting {}", number(numbers, Slot::Setting, index))
}
}
}
pub(crate) fn mode_name(modes: &[MidiMode], mode: u8) -> String {
modes.get(usize::from(mode)).map_or_else(
|| format!("#{} (missing)", u16::from(mode) + 1),
|mode| mode.name.clone(),
)
}
pub(crate) fn beat_text(start: f32, end: f32, every_beat: bool, blinks: bool) -> String {
let window = if every_beat {
let part = |beat: f32| match (beat * 4.0).round() as i32 {
0 => "the start".to_owned(),
1 => "¼".to_owned(),
2 => "the half".to_owned(),
3 => "¾".to_owned(),
4 => "the end".to_owned(),
_ => format!("{beat}"),
};
if start == 0.0 && end == 0.5 {
"every beat, first half".to_owned()
} else if start == 0.5 && end == 1.0 {
"every beat, second half".to_owned()
} else {
format!("every beat, from {} to {}", part(start), part(end))
}
} else if start.fract() == 0.0 && (end - start).rem_euclid(4.0) == 1.0 {
format!("once per bar: beat {}", start + 1.0)
} else {
format!("once per bar: beat {} to {}", start + 1.0, end + 1.0)
};
if blinks {
format!("{window}, blinks in blackout")
} else {
window
}
}
pub(crate) fn action_target(action: &MidiInputAction) -> Option<&MidiSceneTarget> {
use MidiInputAction as A;
match action {
A::SelectScene { target }
| A::ToggleSceneActive { target }
| A::SetSceneActive { target }
| A::FlashScene { target }
| A::SetSceneOpacity { target }
| A::SetSceneInputDimmer { target }
| A::SetSceneBeatOffset { target }
| A::SetSceneIgnoreMainDimmer { target }
| A::SetSceneSetOffsetOnFlash { target }
| A::SetSceneEffectSettingF32 { target, .. }
| A::SetSceneEffectOpacity { target, .. }
| A::SetSceneEffectColorShift { target, .. }
| A::SetSceneEffectBeatProgression { target, .. }
| A::SetSceneEffectBeatOffset { target, .. }
| A::SetSceneEffectSpeedExponent { target, .. }
| A::SetSceneEffectLayout { target, .. } => Some(target),
_ => None,
}
}
pub(crate) fn action_effect(action: &MidiInputAction) -> Option<&MidiEffectTarget> {
use MidiInputAction as A;
match action {
A::SetSceneEffectSettingF32 { effect, .. }
| A::SetSceneEffectOpacity { effect, .. }
| A::SetSceneEffectColorShift { effect, .. }
| A::SetSceneEffectBeatProgression { effect, .. }
| A::SetSceneEffectBeatOffset { effect, .. }
| A::SetSceneEffectSpeedExponent { effect, .. }
| A::SetSceneEffectLayout { effect, .. } => Some(effect),
_ => None,
}
}
fn action_encoder_steps(action: &MidiInputAction) -> Option<u8> {
use MidiInputAction as A;
match action {
A::SetMainDimmerRelative { encoder }
| A::SetSpeedRelative { encoder }
| A::SelectSceneRelative { encoder }
| A::SelectSceneRow { encoder }
| A::SelectSceneColumn { encoder }
| A::SelectSceneEffectRelative { encoder } => Some(encoder.steps_per_unit),
_ => None,
}
}
pub(crate) fn source_target(source: &MidiValueSource) -> Option<&MidiSceneTarget> {
use MidiValueSource as S;
match source {
S::SceneOpacity { target }
| S::SceneOpacityLive { target }
| S::SceneInputDimmer { target }
| S::SceneBeatOffset { target }
| S::SceneIgnoreMainDimmer { target }
| S::SceneSetOffsetOnFlash { target }
| S::SceneActive { target }
| S::SceneFlashed { target }
| S::SceneSelected { target }
| S::SceneEffectSettingF32 { target, .. }
| S::SceneEffectOpacity { target, .. }
| S::SceneEffectColorShift { target, .. }
| S::SceneEffectBeatProgression { target, .. }
| S::SceneEffectBeatOffset { target, .. }
| S::SceneEffectSpeedExponent { target, .. }
| S::SceneEffectLayout { target, .. } => Some(target),
_ => None,
}
}
pub(crate) fn source_effect(source: &MidiValueSource) -> Option<&MidiEffectTarget> {
use MidiValueSource as S;
match source {
S::SelectedSceneEffectLive { effect, .. }
| S::SceneEffectSettingF32 { effect, .. }
| S::SceneEffectOpacity { effect, .. }
| S::SceneEffectColorShift { effect, .. }
| S::SceneEffectBeatProgression { effect, .. }
| S::SceneEffectBeatOffset { effect, .. }
| S::SceneEffectSpeedExponent { effect, .. }
| S::SceneEffectLayout { effect, .. } => Some(effect),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
struct CountingColumns;
impl Numbers for CountingColumns {
fn numbering(&self, slot: Slot) -> Numbering {
match slot {
Slot::Column => Numbering::Counting { offset: 0 },
Slot::Setting => Numbering::Counting { offset: -4 },
_ => Numbering::Same,
}
}
}
#[test]
fn a_fader_rule_reads_as_a_sentence() {
let action = MidiInputAction::SetSceneOpacity {
target: MidiSceneTarget::Row { col: 2 },
};
let pieces = action_sentence(&action, &[], &Plain);
assert_eq!(
text(&pieces),
"sets the opacity of scene 3 in the selected row"
);
assert_eq!(
pieces[0],
Piece::Token {
kind: TokenKind::Main,
text: "sets the opacity of".to_owned()
}
);
}
#[test]
fn a_group_counts_with_n() {
let action = MidiInputAction::FlashScene {
target: MidiSceneTarget::Grid { row: 0, col: 4 },
};
assert_eq!(
text(&action_sentence(&action, &[], &CountingColumns)),
"flashes grid scene row 1, column n"
);
let setting = MidiInputAction::SetSceneEffectSettingF32 {
target: MidiSceneTarget::Selected,
effect: MidiEffectTarget::Selected,
setting_index: 0,
};
assert_eq!(
text(&action_sentence(&setting, &[], &CountingColumns)),
"sets setting n−4 of the selected animation in the selected scene"
);
}
#[test]
fn a_mode_switch_names_its_mode() {
let modes = [MidiMode {
name: "Rows".to_owned(),
highlight: None,
}];
let action = MidiInputAction::SelectMode { mode: 0 };
assert_eq!(
text(&action_sentence(&action, &modes, &Plain)),
"switches the controller to mode Rows"
);
}
#[test]
fn beat_lights_say_whether_they_pulse_per_beat_or_per_bar() {
assert_eq!(beat_text(0.0, 0.5, true, false), "every beat, first half");
assert_eq!(beat_text(0.0, 1.0, false, false), "once per bar: beat 1");
assert_eq!(
beat_text(2.0, 3.0, false, true),
"once per bar: beat 3, blinks in blackout"
);
}
}