use egui::{Align, Frame, Layout, Popup, PopupCloseBehavior, RichText, Stroke, Ui};
use egui_phosphor_icons::icons;
use gled_model::asset::midi_controller::MidiControllerMapping;
use super::intent::{Intent, SlotNumbering};
use super::list_state::ListState;
use super::pickers::GroupFirst;
use super::rules::{
Control, ControlKind, GroupNumbers, Members, Numbering, Numbers, Place, Plain, Rule, RuleGroup,
TokenKind, action_sentence, mode_name,
};
use super::token_editors::{ActionChoice, action_token_editor};
use super::widgets::token::{sentence, token, token_color};
pub(crate) struct Cards<'a> {
pub(crate) mapping: &'a MidiControllerMapping,
pub(crate) controls: &'a [Control],
pub(crate) rules: &'a [Rule],
pub(crate) state: &'a mut ListState,
pub(crate) intents: &'a mut Vec<Intent>,
pub(crate) previewed: &'a mut Vec<usize>,
}
pub(crate) struct Shown<'a> {
pub(crate) members: Members,
pub(crate) group: Option<&'a RuleGroup>,
}
impl Shown<'_> {
pub(crate) fn first(&self) -> &Rule {
&self.members.rules[0]
}
pub(crate) fn numbering(&self, place: Place) -> SlotNumbering {
let Some(group) = self.group else {
return SlotNumbering::default();
};
SlotNumbering(
group
.counting
.iter()
.filter(|(counted, _, _)| *counted == place)
.map(|(_, slot, offset)| (*slot, Numbering::Counting { offset: *offset }))
.collect(),
)
}
pub(crate) fn numbers(&self, place: Place) -> Box<dyn Numbers + '_> {
match self.group {
Some(group) => Box::new(GroupNumbers { group, place }),
None => Box::new(Plain),
}
}
pub(crate) fn group_first<'c>(&self, controls: &'c [Control]) -> Option<GroupFirst<'c>> {
self.group.map(|group| GroupFirst {
n: group.numbers[0],
name: &controls[self.first().control].name,
})
}
}
pub(crate) fn rule_card(ui: &mut Ui, cards: &mut Cards, rule: usize) {
let shown = Shown {
members: Members::single(cards.rules[rule].clone()),
group: None,
};
card(ui, cards, &shown);
}
pub(crate) fn group_card(ui: &mut Ui, cards: &mut Cards, group: &RuleGroup) {
let shown = Shown {
members: Members::of_group(cards.rules, group),
group: Some(group),
};
card(ui, cards, &shown);
}
fn card(ui: &mut Ui, cards: &mut Cards, shown: &Shown) {
let first = shown.first().clone();
let key = (first.control, first.mode);
let highlighted = cards.state.highlight.is_some_and(|(control, _)| {
shown
.members
.rules
.iter()
.any(|rule| rule.control == control)
});
let stroke = if highlighted {
Stroke::new(1.5, token_color(TokenKind::Main))
} else {
ui.visuals().widgets.noninteractive.bg_stroke
};
let response = Frame::group(ui.style())
.stroke(stroke)
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
title(ui, cards, shown);
ui.with_layout(Layout::right_to_left(Align::Min), |ui| {
card_buttons(ui, cards, shown);
ui.with_layout(
Layout::left_to_right(Align::Min).with_main_wrap(true),
|ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
action_line(ui, cards, shown);
},
);
});
});
if let Some(group) = shown.group {
member_cells(ui, cards, shown, group);
}
conflicts(ui, cards, shown);
super::feedback_lines::feedback_lines(ui, cards, shown);
if cards.state.midi_open.contains(&key) && shown.group.is_none() {
super::midi_details::midi_details(ui, cards, &first);
}
if cards.state.copying == Some(key) && shown.group.is_none() {
super::copy_dialog::copy_dialog(ui, cards, &first);
}
})
.response;
if cards.state.scroll_to == Some(first.control) {
response.scroll_to_me(Some(Align::Center));
cards.state.scroll_to = None;
}
if let Some(group) = shown.group
&& let Some((first_control, mode, member)) = cards.state.open_member
&& first_control == first.control
&& mode == first.mode
&& let Some(position) = group
.rules
.iter()
.position(|&rule| cards.rules[rule].control == member)
{
ui.indent(("member", first.control, first.mode), |ui| {
rule_card(ui, cards, group.rules[position]);
});
}
}
fn title(ui: &mut Ui, cards: &mut Cards, shown: &Shown) {
let first = shown.first();
let control = &cards.controls[first.control];
let icon = match control.kind {
ControlKind::Button => icons::CIRCLE,
ControlKind::Pad => icons::SQUARE,
ControlKind::Fader => icons::SLIDERS_HORIZONTAL,
ControlKind::Knob => icons::FADERS,
ControlKind::Encoder => icons::ARROWS_CLOCKWISE,
};
ui.label(icon.regular().weak());
let Some(group) = shown.group else {
let chip = ui
.button(RichText::new(&control.name).strong())
.on_hover_text("Rename, re-learn or see its MIDI message");
Popup::menu(&chip)
.close_behavior(PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| control_menu(ui, cards, first.control));
return;
};
let last = &cards.controls[shown.members.rules[shown.members.rules.len() - 1].control];
let name = match (group.numbers.first(), group.numbers.last()) {
(Some(from), Some(to)) if !control.named_by_message => {
let text = control
.name
.trim_end_matches(|ch: char| ch.is_ascii_digit())
.trim_end();
format!("{text} {from}–{to}")
}
_ => message_range(&control.trigger, &last.trigger),
};
ui.label(RichText::new(name).strong());
ui.label("each");
}
fn control_menu(ui: &mut Ui, cards: &mut Cards, control: usize) {
let name = &cards.controls[control].name;
let editing = cards
.state
.renaming
.get_or_insert_with(|| (control, name.clone()));
if editing.0 != control {
*editing = (control, name.clone());
}
ui.label("Name");
let field = ui.text_edit_singleline(&mut editing.1);
if field.lost_focus() || ui.input(|input| input.key_pressed(egui::Key::Enter)) {
let new_name = editing.1.trim().to_owned();
if !new_name.is_empty() && new_name != *name {
cards.intents.push(Intent::RenameControl {
control,
name: new_name,
});
}
}
ui.weak(super::rules::message_name(&cards.controls[control].trigger));
if ui.button("Learn it again: move the control").clicked() {
cards.state.learn_request = Some(super::list_state::LearnRequest::Control(control));
ui.close();
}
}
fn action_line(ui: &mut Ui, cards: &mut Cards, shown: &Shown) {
let first = shown.first();
let control = &cards.controls[first.control];
let mapping = cards.mapping;
let action = first
.press
.or(first.release)
.map(|index| &mapping.input_bindings[index].action);
let numbering = shown.numbering(Place::Action);
let open_palette = cards.state.open_palette == Some((first.control, first.mode));
if open_palette {
cards.state.open_palette = None;
}
let mut choices = Vec::new();
match action {
Some(action) => {
let numbers = shown.numbers(Place::Action);
let pieces = action_sentence(action, &mapping.modes, numbers.as_ref());
for (kind, response) in sentence(ui, &pieces) {
let open = open_palette && kind == TokenKind::Main;
choices.push((
kind,
action_token_editor(
ui,
&response,
kind,
Some(action),
&numbering,
&mapping.modes,
control.kind,
shown.group_first(cards.controls),
open,
),
));
}
if ui
.small_button(icons::X)
.on_hover_text("Remove the action; the lights stay")
.clicked()
{
cards
.intents
.push(Intent::RemoveAction(shown.members.clone()));
}
}
None => {
let response = token(
ui,
"does nothing yet — choose an action",
token_color(TokenKind::Main),
);
choices.push((
TokenKind::Main,
action_token_editor(
ui,
&response,
TokenKind::Main,
None,
&numbering,
&mapping.modes,
control.kind,
None,
open_palette,
),
));
}
}
when_token(ui, cards, shown);
mode_token(ui, cards, shown);
touch_token(ui, cards, shown);
for (kind, choice) in choices {
match choice {
Some(ActionChoice::Set(action, numbering)) => {
let numbering = if kind == TokenKind::Main {
shown.numbering(Place::Action)
} else {
numbering
};
cards.intents.push(Intent::SetAction {
members: shown.members.clone(),
action,
numbering,
});
}
Some(ActionChoice::RemoveAction) => cards
.intents
.push(Intent::RemoveAction(shown.members.clone())),
Some(ActionChoice::DeleteRule) => cards
.intents
.push(Intent::RemoveRules(shown.members.clone())),
None => {}
}
}
}
fn when_token(ui: &mut Ui, cards: &mut Cards, shown: &Shown) {
let first = shown.first();
let control = &cards.controls[first.control];
if !matches!(control.kind, ControlKind::Button | ControlKind::Pad) {
return;
}
let text = match (first.press, first.release) {
(Some(_), Some(_)) if first.while_held(cards.mapping) => "while held",
(None, Some(_)) => "on release",
(Some(_), _) => "on press",
(None, None) => return,
};
ui.label("·");
let response = token(ui, text, token_color(TokenKind::Beat));
if first.press.is_none() {
return;
}
let held = text == "while held";
Popup::menu(&response)
.close_behavior(PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
if ui.selectable_label(!held, "On press").clicked() && held {
cards.intents.push(Intent::SetWhileHeld {
members: shown.members.clone(),
held: false,
});
ui.close();
}
if ui
.selectable_label(held, "While held: undone when you let go")
.clicked()
&& !held
{
cards.intents.push(Intent::SetWhileHeld {
members: shown.members.clone(),
held: true,
});
ui.close();
}
});
}
fn mode_token(ui: &mut Ui, cards: &mut Cards, shown: &Shown) {
let mapping = cards.mapping;
if mapping.modes.is_empty() {
return;
}
let first = shown.first();
ui.label("· works in");
let text = match first.mode {
None => "all modes".to_owned(),
Some(mode) => format!("mode {}", mode_name(&mapping.modes, mode)),
};
let response = token(ui, &text, token_color(TokenKind::Mode));
Popup::menu(&response)
.close_behavior(PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
let mut pick = |ui: &mut Ui, mode: Option<u8>, label: String| {
if ui.selectable_label(first.mode == mode, label).clicked() && first.mode != mode {
cards.intents.push(Intent::SetMode {
members: shown.members.clone(),
mode,
});
ui.close();
}
};
pick(ui, None, "All modes".to_owned());
for (index, entry) in mapping.modes.iter().enumerate() {
pick(
ui,
u8::try_from(index).ok(),
format!("Only in {}", entry.name),
);
}
});
}
fn touch_token(ui: &mut Ui, cards: &mut Cards, shown: &Shown) {
let Some(press) = shown.first().press else {
return;
};
let Some(touch) = &cards.mapping.input_bindings[press].touch else {
return;
};
ui.label("·");
let text = if shown.group.is_some() {
format!("touch notes {}…", touch.data1)
} else {
format!("touch note {}", touch.data1)
};
token(ui, &text, token_color(TokenKind::Encoder)).on_hover_text(
"The note the control sends while a finger rests on it; gled outlines what it changes meanwhile. \
Change it under MIDI.",
);
}
fn card_buttons(ui: &mut Ui, cards: &mut Cards, shown: &Shown) {
let first = shown.first();
let key = (first.control, first.mode);
if ui
.small_button(icons::TRASH)
.on_hover_text("Delete this rule with its lights")
.clicked()
{
cards
.intents
.push(Intent::RemoveRules(shown.members.clone()));
}
if shown.group.is_none() {
let open = cards.state.midi_open.contains(&key);
if ui
.selectable_label(open, "MIDI")
.on_hover_text("The MIDI messages of this rule")
.clicked()
{
if open {
cards.state.midi_open.retain(|other| *other != key);
} else {
cards.state.midi_open.push(key);
}
}
let copying = cards.state.copying == Some(key);
if ui
.selectable_label(copying, super::iconized(ui, icons::COPY, " Copy…"))
.on_hover_text("Copy this rule to other controls, counting its numbers up")
.clicked()
{
cards.state.copying = (!copying).then_some(key);
}
}
}
fn conflicts(ui: &mut Ui, cards: &mut Cards, shown: &Shown) {
let mapping = cards.mapping;
let acts = |rule: &Rule| rule.press.or(rule.release).is_some();
let mut others: Vec<Rule> = Vec::new();
for member in &shown.members.rules {
if !acts(member) {
continue;
}
for other in cards.rules {
let overlaps =
member.mode.is_none() || other.mode.is_none() || member.mode == other.mode;
if other.control == member.control
&& other != member
&& overlaps
&& acts(other)
&& !others.contains(other)
{
others.push(other.clone());
}
}
}
let Some(first) = others.first() else {
return;
};
let control = &cards.controls[first.control].name;
let where_ = match first.mode {
Some(mode) => format!("in mode {}", mode_name(&mapping.modes, mode)),
None => "in all modes".to_owned(),
};
let does = first
.press
.or(first.release)
.map(|index| {
super::rules::text(&action_sentence(
&mapping.input_bindings[index].action,
&mapping.modes,
&Plain,
))
})
.unwrap_or_default();
ui.horizontal_wrapped(|ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
ui.add_space(22.0);
ui.colored_label(ui.visuals().warn_fg_color, icons::WARNING.regular());
let more = if others.len() > 1 {
format!(" (and {} more)", others.len() - 1)
} else {
String::new()
};
ui.colored_label(
ui.visuals().warn_fg_color,
format!("{control} also {does} {where_}{more} — both happen"),
);
let label = if others.len() == 1 {
"Remove that rule".to_owned()
} else {
format!("Remove those {} rules", others.len())
};
if ui.small_button(label).clicked() {
cards.intents.push(Intent::RemoveRules(Members {
numbers: vec![1; others.len()],
rules: others.clone(),
}));
}
});
}
fn member_cells(ui: &mut Ui, cards: &mut Cards, shown: &Shown, group: &RuleGroup) {
let first = shown.first();
ui.horizontal_wrapped(|ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
ui.weak("n =");
for (rule, number) in shown.members.rules.iter().zip(&group.numbers) {
let control = &cards.controls[rule.control];
let open = cards.state.open_member == Some((first.control, first.mode, rule.control));
if ui
.selectable_label(open, number.to_string())
.on_hover_text(format!("{} — show its own rule", control.name))
.clicked()
{
cards.state.open_member =
(!open).then_some((first.control, first.mode, rule.control));
}
}
let first_control = &cards.controls[first.control];
let explanation = if first_control.named_by_message {
"(n is the control's place in the group, from 1)".to_owned()
} else {
format!(
"(n is the number in the control's name: {} has n = {})",
first_control.name, group.numbers[0]
)
};
ui.weak(explanation);
});
}
fn message_range(
first: &gled_model::asset::midi_controller::MidiTrigger,
last: &gled_model::asset::midi_controller::MidiTrigger,
) -> String {
let first_channel = (first.status & 0x0F) + 1;
let last_channel = (last.status & 0x0F) + 1;
let kind = match first.status & 0xF0 {
0x80 | 0x90 => "Note",
0xB0 => "CC",
0xE0 => "Pitch bend",
_ => "Message",
};
if first_channel != last_channel {
if first.status & 0xF0 == 0xE0 {
format!("{kind} ch {first_channel}–{last_channel}")
} else {
format!("{kind} {} · ch {first_channel}–{last_channel}", first.data1)
}
} else if first_channel == 1 {
format!("{kind} {}–{}", first.data1, last.data1)
} else {
format!("{kind} {}–{} · ch {first_channel}", first.data1, last.data1)
}
}