gled-ui 2.34.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! The rules of a controller as a list: chips to show all of them, those
//! working in every mode, or one mode's; a search; and a section per mode
//! with a card per rule or group.

use egui::{Popup, PopupCloseBehavior, RichText, TextEdit, Ui};
use egui_phosphor_icons::icons;
use gled_model::asset::midi_controller::MidiModeHighlight;
use strum::IntoEnumIterator;

use super::intent::Intent;
use super::list_state::ModeFilter;
use super::rule_card::{Cards, group_card, rule_card};
use super::rules::{ControlKind, Plain, RuleGroup, action_sentence, source_sentence, text};

pub(crate) fn rule_list(ui: &mut Ui, cards: &mut Cards, groups: &[RuleGroup]) {
    let mapping = cards.mapping;
    if cards.rules.is_empty() {
        ui.add_space(20.0);
        ui.vertical_centered(|ui| {
            ui.heading("No rules yet");
            ui.label("Press \"Learn\" above, then move a control on your controller.");
            ui.label(
                "Each control you move gets a rule; pick what it does from the list that opens.",
            );
        });
        return;
    }

    let query = cards.state.find.trim().to_lowercase();
    let mut sections: Vec<Option<u8>> = vec![None];
    sections.extend(
        (0..mapping.modes.len())
            .filter_map(|mode| u8::try_from(mode).ok())
            .map(Some),
    );
    let mut shown = 0;
    for section in sections {
        let visible = match cards.state.filter {
            ModeFilter::All => true,
            ModeFilter::Everywhere => section.is_none(),
            ModeFilter::Mode(mode) => section == Some(mode),
        };
        if !visible {
            continue;
        }
        let items = section_items(cards, groups, section, &query);
        if items.is_empty() && section.is_none() && !mapping.modes.is_empty() {
            continue;
        }
        section_header(ui, cards, section, items.len());
        if let Some(mode) = section {
            mode_setup(ui, cards, mode);
        }
        for item in items {
            match item {
                Item::Group(group) => group_card(ui, cards, &groups[group]),
                Item::Rule(rule) => rule_card(ui, cards, rule),
            }
            shown += 1;
        }
        if section.is_some() && !query.is_empty() {
            continue;
        }
        if ui
            .small_button(super::iconized(
                ui,
                icons::PLUS,
                " Add a rule here: move a control",
            ))
            .clicked()
        {
            cards.state.learn_request = Some(super::list_state::LearnRequest::NewControls);
            cards.state.filter = match section {
                Some(mode) => ModeFilter::Mode(mode),
                None if mapping.modes.is_empty() => ModeFilter::All,
                None => ModeFilter::Everywhere,
            };
        }
        ui.add_space(8.0);
    }
    if shown == 0 && !query.is_empty() {
        ui.weak(format!("No rule mentions \"{}\"", cards.state.find.trim()));
    }
}

/// A card in a section.
enum Item {
    Group(usize),
    Rule(usize),
}

/// The cards of a section, in control order; a group stands for its rules.
fn section_items(
    cards: &Cards,
    groups: &[RuleGroup],
    section: Option<u8>,
    query: &str,
) -> Vec<Item> {
    let grouped: Vec<usize> = groups
        .iter()
        .flat_map(|group| group.rules.iter().copied())
        .collect();
    let mut items: Vec<(usize, Item)> = Vec::new();
    for (index, group) in groups.iter().enumerate() {
        let first = &cards.rules[group.rules[0]];
        if first.mode == section && group.rules.iter().any(|&rule| matches(cards, rule, query)) {
            items.push((first.control, Item::Group(index)));
        }
    }
    for (index, rule) in cards.rules.iter().enumerate() {
        if rule.mode == section && !grouped.contains(&index) && matches(cards, index, query) {
            items.push((rule.control, Item::Rule(index)));
        }
    }
    // Encoders and knobs first, then faders, then buttons and pads, each
    // in their controls' order.
    items.sort_by_key(|(control, _)| {
        let kind = match cards.controls[*control].kind {
            ControlKind::Encoder => 0,
            ControlKind::Knob => 1,
            ControlKind::Fader => 2,
            ControlKind::Button | ControlKind::Pad => 3,
        };
        (kind, *control)
    });
    items.into_iter().map(|(_, item)| item).collect()
}

/// Whether the rule's control name contains `query`, or, when no control
/// name does, its sentences.
fn matches(cards: &Cards, rule: usize, query: &str) -> bool {
    if query.is_empty() {
        return true;
    }
    let names_match = cards
        .controls
        .iter()
        .any(|control| control.name.to_lowercase().contains(query));
    let rule = &cards.rules[rule];
    if names_match {
        return cards.controls[rule.control]
            .name
            .to_lowercase()
            .contains(query);
    }
    let mapping = cards.mapping;
    let mut words = String::new();
    if let Some(index) = rule.press.or(rule.release) {
        words += &text(&action_sentence(
            &mapping.input_bindings[index].action,
            &mapping.modes,
            &Plain,
        ));
    }
    for &output in &rule.feedback {
        if let Some(source) = mapping.output_bindings[output].kind.source() {
            words += &text(&source_sentence(source, &mapping.modes, &Plain));
        }
    }
    words.to_lowercase().contains(query)
}

fn section_header(ui: &mut Ui, cards: &Cards, section: Option<u8>, count: usize) {
    let mapping = cards.mapping;
    ui.horizontal(|ui| {
        match section {
            None if mapping.modes.is_empty() => {
                ui.label(RichText::new("Rules").strong());
            }
            None => {
                ui.label(RichText::new("In all modes").strong());
                ui.weak("always active, whatever mode the controller is in");
            }
            Some(mode) => {
                let name = mapping
                    .modes
                    .get(usize::from(mode))
                    .map_or("?", |mode| mode.name.as_str());
                ui.label(RichText::new(format!("Mode {name}")).strong());
                let switches: Vec<&str> = cards
                    .rules
                    .iter()
                    .filter(|rule| {
                        rule.press.is_some_and(|press| {
                            mapping.input_bindings[press].action
                                == gled_model::asset::midi_controller::MidiInputAction::SelectMode {
                                    mode,
                                }
                        })
                    })
                    .map(|rule| cards.controls[rule.control].name.as_str())
                    .collect();
                if switches.is_empty() {
                    ui.weak("no button switches to it yet");
                } else {
                    ui.weak(format!("{} switches to it", switches.join(", ")));
                }
            }
        }
        ui.weak(format!("· {count}"));
    });
}

/// Under a mode's heading: the button that switches to it, and what
/// lights up in gled when it does.
fn mode_setup(ui: &mut Ui, cards: &mut Cards, mode: u8) {
    let mapping = cards.mapping;
    let Some(entry) = mapping.modes.get(usize::from(mode)) else {
        return;
    };
    ui.horizontal_wrapped(|ui| {
        if ui
            .small_button("Choose the button that switches to it…")
            .on_hover_text(
                "Then press the button on your controller; it gets a light showing the mode",
            )
            .clicked()
        {
            cards.state.learn_request = Some(super::list_state::LearnRequest::ModeSwitch(mode));
        }
        ui.weak("· highlight in gled when switching here:");
        let label = |highlight: Option<MidiModeHighlight>| {
            highlight.map_or_else(
                || "nothing".to_owned(),
                |highlight| highlight.to_string().to_lowercase(),
            )
        };
        let response = super::widgets::token::token(
            ui,
            &label(entry.highlight),
            super::widgets::token::token_color(super::rules::TokenKind::Mode),
        );
        Popup::menu(&response)
            .close_behavior(PopupCloseBehavior::CloseOnClickOutside)
            .show(|ui| {
                for option in std::iter::once(None).chain(MidiModeHighlight::iter().map(Some)) {
                    if ui
                        .selectable_label(entry.highlight == option, label(option))
                        .clicked()
                    {
                        cards.intents.push(Intent::SetHighlight {
                            mode,
                            highlight: option,
                        });
                        ui.close();
                    }
                }
            });
    });
}

/// "All · In all modes · Rows · Animations · + Mode" and the search.
pub(crate) fn mode_chips(ui: &mut Ui, cards: &mut Cards) {
    let mapping = cards.mapping;
    let count = |mode: Option<Option<u8>>| {
        cards
            .rules
            .iter()
            .filter(|rule| mode.is_none_or(|mode| rule.mode == mode))
            .count()
    };
    ui.horizontal_wrapped(|ui| {
        let filter = &mut cards.state.filter;
        ui.selectable_value(filter, ModeFilter::All, format!("All {}", count(None)));
        if !mapping.modes.is_empty() {
            ui.selectable_value(
                filter,
                ModeFilter::Everywhere,
                format!("In all modes {}", count(Some(None))),
            )
            .on_hover_text("Rules that work whatever mode the controller is in");
            for (index, mode) in mapping.modes.iter().enumerate() {
                let Ok(index) = u8::try_from(index) else {
                    continue;
                };
                ui.selectable_value(
                    filter,
                    ModeFilter::Mode(index),
                    format!("{} {}", mode.name, count(Some(Some(index)))),
                );
            }
        }
        let add = ui
            .button(super::iconized(ui, icons::PLUS, " Mode"))
            .on_hover_text(
                "Modes let the same controls do different things; a button switches between them",
            );
        Popup::menu(&add)
            .close_behavior(PopupCloseBehavior::CloseOnClickOutside)
            .show(|ui| {
                let id = ui.make_persistent_id("new mode name");
                let mut name: String = ui.data(|data| data.get_temp(id)).unwrap_or_default();
                ui.label("Name of the new mode");
                let field = ui.add(TextEdit::singleline(&mut name).hint_text("e.g. Grid"));
                field.request_focus();
                let done = ui.input(|input| input.key_pressed(egui::Key::Enter));
                if (done || ui.button("Add mode").clicked()) && !name.trim().is_empty() {
                    cards.intents.push(Intent::AddMode(name.trim().to_owned()));
                    cards.state.filter =
                        ModeFilter::Mode(u8::try_from(mapping.modes.len()).unwrap_or(u8::MAX));
                    name.clear();
                    ui.close();
                }
                ui.data_mut(|data| data.insert_temp(id, name));
            });
        ui.add_space(12.0);
        let find = ui.add(
            TextEdit::singleline(&mut cards.state.find)
                .hint_text("Find rules  (/)")
                .desired_width(200.0)
                .id_salt("find rules"),
        );
        if std::mem::take(&mut cards.state.focus_find) {
            find.request_focus();
        }
    });
}