gled-ui 2.34.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! The tabs beside the rules: the MIDI monitor and ports, the color
//! mappings, the modes, and the devices that use this controller.

use egui::{ComboBox, Grid, RichText, ScrollArea, Ui};
use egui_phosphor_icons::icons;
use gled_midi::monitor::label;
use gled_model::asset::midi_controller::{MidiController, MidiModeHighlight, MidiTrigger};
use gled_protocol::midi::MidiMonitorEvent;
use std::collections::VecDeque;
use strum::IntoEnumIterator;

use super::intent::Intent;
use super::list_state::{LearnRequest, ListState};
use super::rules::{Control, control_index, message_name};
use crate::midi_learn::MidiPortDiagnostics;

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) enum SideTab {
    #[default]
    Monitor,
    Colors,
    Modes,
    Devices,
}

impl SideTab {
    fn label(self) -> &'static str {
        match self {
            Self::Monitor => "Monitor",
            Self::Colors => "Colors",
            Self::Modes => "Modes",
            Self::Devices => "Devices",
        }
    }
}

pub(crate) fn tab_bar(ui: &mut Ui, tab: &mut SideTab) {
    ui.horizontal(|ui| {
        for option in [
            SideTab::Monitor,
            SideTab::Colors,
            SideTab::Modes,
            SideTab::Devices,
        ] {
            ui.selectable_value(tab, option, option.label());
        }
    });
    ui.separator();
}

/// Ports and incoming messages, each named by the control that sent it.
pub(crate) fn monitor_tab(
    ui: &mut Ui,
    events: &mut VecDeque<MidiMonitorEvent>,
    diagnostics: &[MidiPortDiagnostics],
    controls: &[Control],
) {
    ui.label(RichText::new("Ports").strong());
    if diagnostics.is_empty() {
        ui.weak("No MIDI ports seen yet. Connect a controller.");
    }
    for port in diagnostics {
        ui.group(|ui| {
            ui.label(&port.port_name);
            let state = |connected: bool| {
                if connected {
                    "connected"
                } else {
                    "not connected"
                }
            };
            ui.weak(format!(
                "input {} · output {} · {} messages",
                state(port.input_connected),
                state(port.output_connected),
                port.message_count
            ));
            if port.input_connect_failed + port.output_connect_failed > 0 {
                ui.colored_label(
                    ui.visuals().warn_fg_color,
                    format!(
                        "{} failed attempts to connect — is another program using the port?",
                        port.input_connect_failed + port.output_connect_failed
                    ),
                );
            }
        });
    }
    ui.add_space(6.0);
    ui.horizontal(|ui| {
        ui.label(RichText::new("Incoming MIDI").strong());
        if ui.small_button("Clear").clicked() {
            events.clear();
        }
    });
    ui.weak("Move a control: its message shows here with the control it belongs to.");
    ScrollArea::vertical()
        .id_salt("midi monitor")
        .show(ui, |ui| {
            Grid::new("midi monitor rows").striped(true).show(ui, |ui| {
                for event in events
                    .iter()
                    .rev()
                    .filter(|event| event.label == label::MIDI)
                    .take(200)
                {
                    let [status, data1, value] = match event.bytes.as_slice() {
                        [status, data1, value, ..] => [*status, *data1, *value],
                        [status, data1] => [*status, *data1, 0],
                        _ => continue,
                    };
                    let trigger = MidiTrigger {
                        status,
                        data1,
                        match_data1: true,
                    };
                    ui.label(message_name(&trigger));
                    ui.label(value.to_string());
                    let control = control_index(controls, &trigger).map(|index| &controls[index]);
                    match control {
                        Some(control) => ui.label(&control.name),
                        None => ui.weak("not mapped"),
                    };
                    ui.end_row();
                }
            });
        });
}

/// The modes: name, what lights up in gled when switching to it, order,
/// the button that switches to it, removal.
pub(crate) fn modes_tab(
    ui: &mut Ui,
    controller: &MidiController,
    intents: &mut Vec<Intent>,
    state: &mut ListState,
) {
    let modes = &controller.mapping.modes;
    ui.add(
        egui::Label::new(
            "Modes let the same controls do different things. A button switches between them; \
         the first mode is active at start.",
        )
        .wrap(),
    );
    ui.add_space(4.0);
    for (index, mode) in modes.iter().enumerate() {
        let Ok(index) = u8::try_from(index) else {
            continue;
        };
        ui.group(|ui| {
            ui.set_width(ui.available_width());
            ui.horizontal(|ui| {
                let id = ui.make_persistent_id(("mode name", index));
                let mut name: String = ui
                    .data(|data| data.get_temp(id))
                    .unwrap_or_else(|| mode.name.clone());
                let field = ui.add(egui::TextEdit::singleline(&mut name).desired_width(200.0));
                if field.lost_focus() && name.trim() != mode.name && !name.trim().is_empty() {
                    intents.push(Intent::RenameMode {
                        mode: index,
                        name: name.trim().to_owned(),
                    });
                }
                if field.has_focus() {
                    ui.data_mut(|data| data.insert_temp(id, name));
                } else {
                    ui.data_mut(|data| data.remove::<String>(id));
                }
                if index > 0
                    && ui
                        .small_button(icons::ARROW_UP)
                        .on_hover_text("Move up")
                        .clicked()
                {
                    intents.push(Intent::MoveMode {
                        from: index,
                        to: index - 1,
                    });
                }
                if usize::from(index) + 1 < modes.len()
                    && ui
                        .small_button(icons::ARROW_DOWN)
                        .on_hover_text("Move down")
                        .clicked()
                {
                    intents.push(Intent::MoveMode {
                        from: index,
                        to: index + 1,
                    });
                }
            });
            if index == 0 {
                ui.weak("The controller starts in this mode");
            }
            ui.horizontal(|ui| {
                ui.label("Highlights in gled");
                let label = |highlight: Option<MidiModeHighlight>| {
                    highlight
                        .map_or_else(|| "Nothing".to_owned(), |highlight| highlight.to_string())
                };
                ComboBox::from_id_salt(("mode highlight", index))
                    .width(130.0)
                    .selected_text(label(mode.highlight))
                    .show_ui(ui, |ui| {
                        for option in
                            std::iter::once(None).chain(MidiModeHighlight::iter().map(Some))
                        {
                            if ui
                                .selectable_label(mode.highlight == option, label(option))
                                .clicked()
                            {
                                intents.push(Intent::SetHighlight {
                                    mode: index,
                                    highlight: option,
                                });
                            }
                        }
                    });
            })
            .response
            .on_hover_text(
                "What lights up in gled for a moment when the controller switches to this mode",
            );
            if ui
                .button("Choose the button that switches to it…")
                .on_hover_text("Then press the button on your controller")
                .clicked()
            {
                state.learn_request = Some(LearnRequest::ModeSwitch(index));
            }
            let uses = controller.mapping.mode_uses(index);
            if uses == 0 {
                if ui
                    .button(super::iconized(ui, icons::TRASH, " Remove mode"))
                    .clicked()
                {
                    intents.push(Intent::RemoveMode(index));
                }
            } else {
                ui.add(
                    egui::Label::new(
                        RichText::new(format!(
                            "Can't be removed while {uses} bindings work in it or switch to it; \
                             move or delete those first"
                        ))
                        .weak(),
                    )
                    .wrap(),
                );
            }
        });
    }
}

/// The devices that get this controller automatically.
pub(crate) fn devices_tab(
    ui: &mut Ui,
    ports: &mut Vec<String>,
    known_ports: &[String],
    others: &[super::OtherController],
    take_over: &mut Option<(gled_model::asset_id::AssetId<MidiController>, String)>,
) -> bool {
    let mut changed = false;
    ui.label(RichText::new("Use automatically for").strong());
    ui.weak(
        "A connected device on one of these ports gets this controller, \
         unless the project assigns it another one or none.",
    );
    let mut remove = None;
    for (index, port) in ports.iter().enumerate() {
        ui.horizontal(|ui| {
            ui.label(super::test_panel::short_port_name(port))
                .on_hover_text(port);
            if ui
                .small_button(icons::X)
                .on_hover_text("Stop using it for this device")
                .clicked()
            {
                remove = Some(index);
            }
        });
        for other in others.iter().filter(|other| other.ports.contains(port)) {
            ui.horizontal_wrapped(|ui| {
                ui.colored_label(
                    ui.visuals().warn_fg_color,
                    format!("{} is used for it too; gled takes one of them", other.name),
                );
                if ui
                    .small_button("Take over")
                    .on_hover_text(format!("Stop using {} for this device", other.name))
                    .clicked()
                {
                    *take_over = Some((other.id, port.clone()));
                }
            });
        }
    }
    if let Some(index) = remove {
        ports.remove(index);
        changed = true;
    }
    let addable: Vec<String> = known_ports
        .iter()
        .map(|port| gled_model::asset::midi_controller::normalize_controller_key(port))
        .filter(|port| !ports.contains(port))
        .collect::<std::collections::BTreeSet<_>>()
        .into_iter()
        .collect();
    if addable.is_empty() {
        if ports.is_empty() {
            ui.weak("No device connected yet; connect one to add it.");
        }
    } else {
        ComboBox::from_id_salt("add default port")
            .selected_text("Add a connected device…")
            .show_ui(ui, |ui| {
                for port in addable {
                    if ui
                        .selectable_label(false, super::test_panel::short_port_name(&port))
                        .on_hover_text(&port)
                        .clicked()
                    {
                        ports.push(port);
                        changed = true;
                    }
                }
            });
    }
    changed
}