gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! What a MIDI controller shows in the interface: the part a mode switch
//! works on, lit up for a few seconds, and the sliders under a finger on a
//! touch-sensitive fader.

use std::{collections::BTreeSet, sync::Arc};

use std::f32::consts::{FRAC_PI_2, PI};

use egui::{
    Color32, Context, Id, LayerId, Order, Pos2, Rect, Shape, Stroke, StrokeKind, Ui, pos2, vec2,
};
use gled_midi::controller_for_port;
use gled_model::{
    asset::{
        Asset,
        midi_controller::{
            MidiController, MidiEffectParameter, MidiModeHighlight, normalize_controller_key,
        },
        scene::grid::GridLocation,
    },
    collection::Collections,
};
use gled_protocol::state::{EngineState, ModeSwitch, TouchedControl};
use web_time::{Duration, Instant};

/// The color of everything a controller marks.
pub(crate) const CONTROLLER_COLOR: Color32 = Color32::from_rgb(0, 170, 255);

/// How long a mode switch lights up its part. At the end the outline runs
/// once around the part and is gone.
const HIGHLIGHT_TIME: Duration = Duration::from_secs(3);
const RETRACT_TIME: Duration = Duration::from_millis(800);
const CORNER_RADIUS: f32 = 6.0;

/// The part of the interface the last mode switch lights up.
#[derive(Debug, Default)]
pub(crate) struct ModeHighlight {
    /// The number of the switch seen last, `Some(None)` before the first;
    /// `None` until the first state, which only sets it.
    seen: Option<Option<u64>>,
    shown: Option<(MidiModeHighlight, Instant)>,
}

impl ModeHighlight {
    /// Lights up the part of a switch not seen yet.
    pub(crate) fn follow(&mut self, switch: Option<ModeSwitch>) {
        let number = switch.map(|switch| switch.number);
        let new = self.seen.is_some_and(|seen| seen != number);
        self.seen = Some(number);
        if new && let Some(highlight) = switch.and_then(|switch| switch.highlight) {
            self.shown = Some((highlight, Instant::now()));
        }
    }

    /// Outlines `rect` while `area` is lit up.
    pub(crate) fn paint(&self, ctx: &Context, area: MidiModeHighlight, rect: Rect) {
        let Some((shown, since)) = self.shown else {
            return;
        };
        let elapsed = since.elapsed();
        if shown != area || elapsed >= HIGHLIGHT_TIME {
            return;
        }
        // How far the outline has run off, 0 until the end comes, eased.
        let left = HIGHLIGHT_TIME - elapsed;
        let retracted =
            ease_in_out(1.0 - (left.as_secs_f32() / RETRACT_TIME.as_secs_f32()).min(1.0));
        let painter = ctx.layer_painter(LayerId::new(Order::Foreground, Id::new("mode highlight")));
        painter.rect_filled(
            rect,
            CORNER_RADIUS,
            CONTROLLER_COLOR.gamma_multiply(0.12 * (1.0 - retracted)),
        );
        let border = perimeter(rect.shrink(4.0), CORNER_RADIUS);
        let length = path_length(&border);
        let rest = path_from(&border, retracted * length);
        if let Some(head) = rest.first().copied().filter(|_| retracted > 0.0) {
            painter.circle_filled(head, 9.0, CONTROLLER_COLOR.gamma_multiply(0.25));
            painter.circle_filled(
                head,
                4.5,
                Color32::WHITE.lerp_to_gamma(CONTROLLER_COLOR, 0.4),
            );
        }
        painter.add(Shape::line(rest, Stroke::new(4.0, CONTROLLER_COLOR)));
        ctx.request_repaint();
    }
}

fn ease_in_out(t: f32) -> f32 {
    if t < 0.5 {
        4.0 * t * t * t
    } else {
        1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
    }
}

/// The border of `rect` with rounded corners, clockwise from its top center
/// back to it.
fn perimeter(rect: Rect, radius: f32) -> Vec<Pos2> {
    const ARC_STEPS: usize = 8;
    let radius = radius.min(rect.width() / 2.0).min(rect.height() / 2.0);
    let corners = [
        (pos2(rect.right() - radius, rect.top() + radius), -FRAC_PI_2),
        (pos2(rect.right() - radius, rect.bottom() - radius), 0.0),
        (
            pos2(rect.left() + radius, rect.bottom() - radius),
            FRAC_PI_2,
        ),
        (pos2(rect.left() + radius, rect.top() + radius), PI),
    ];
    let mut points = vec![pos2(rect.center().x, rect.top())];
    for (center, start) in corners {
        for step in 0..=ARC_STEPS {
            let angle = start + FRAC_PI_2 * step as f32 / ARC_STEPS as f32;
            points.push(center + radius * vec2(angle.cos(), angle.sin()));
        }
    }
    points.push(pos2(rect.center().x, rect.top()));
    points
}

fn path_length(points: &[Pos2]) -> f32 {
    points
        .windows(2)
        .map(|pair| pair[0].distance(pair[1]))
        .sum()
}

/// The part of the path `points` from `start` along it to its end.
fn path_from(points: &[Pos2], start: f32) -> Vec<Pos2> {
    let mut walked = 0.0;
    for (index, pair) in points.windows(2).enumerate() {
        let segment = pair[0].distance(pair[1]);
        if walked + segment >= start {
            let along = if segment > 0.0 {
                (start - walked) / segment
            } else {
                0.0
            };
            let mut rest = vec![pair[0].lerp(pair[1], along.max(0.0))];
            rest.extend_from_slice(&points[index + 1..]);
            return rest;
        }
        walked += segment;
    }
    Vec::new()
}

impl ModeHighlight {
    #[cfg(test)]
    fn is_shown(&self) -> bool {
        self.shown.is_some()
    }
}

fn touched_id() -> Id {
    Id::new("controller touched controls")
}

fn shown_effect_id() -> Id {
    Id::new("controller shown effect")
}

/// The controls touched in this frame, for the sliders to ask.
pub(crate) fn set_touched(ctx: &Context, controls: &[TouchedControl]) {
    let controls: Arc<[TouchedControl]> = controls.into();
    ctx.data_mut(|data| data.insert_temp(touched_id(), controls));
}

/// The scene and effect the settings panel shows in this frame.
pub(crate) fn set_shown_effect(ctx: &Context, scene: GridLocation, effect: usize) {
    ctx.data_mut(|data| data.insert_temp(shown_effect_id(), (scene, effect)));
}

pub(crate) fn is_touched(ctx: &Context, control: &TouchedControl) -> bool {
    ctx.data(|data| data.get_temp::<Arc<[TouchedControl]>>(touched_id()))
        .is_some_and(|controls| controls.contains(control))
}

/// Whether `parameter` of the effect the settings panel shows is touched.
pub(crate) fn shown_effect_touched(ctx: &Context, parameter: MidiEffectParameter) -> bool {
    ctx.data(|data| data.get_temp::<(GridLocation, usize)>(shown_effect_id()))
        .is_some_and(|(scene, effect)| {
            is_touched(
                ctx,
                &TouchedControl::EffectParameter {
                    scene,
                    effect,
                    parameter,
                },
            )
        })
}

/// A connected controller with modes, as the status bar shows it.
pub(crate) struct ControllerMode {
    /// The device's name without its port.
    pub(crate) device: String,
    pub(crate) mode: String,
    pub(crate) port: String,
    pub(crate) controller: String,
}

/// The mode of each connected controller whose mapping has modes: the
/// engine's ports and `local_ports`, plugged into this interface's machine.
pub(crate) fn controller_modes(
    state: &EngineState,
    collections: &Collections,
    local_ports: &BTreeSet<String>,
) -> Vec<ControllerMode> {
    let Some(project) = state.project.as_deref() else {
        return Vec::new();
    };
    let ports: BTreeSet<String> = state
        .midi_inputs
        .iter()
        .chain(local_ports)
        .map(|port| normalize_controller_key(port))
        .collect();
    ports
        .into_iter()
        .filter_map(|port| {
            let controller = Asset::<MidiController>::get(
                controller_for_port(project, &port, collections)?,
                collections,
            )?;
            let mode = state.midi_modes.get(&port).copied().unwrap_or(0);
            let mode = controller.data.mapping.modes.get(usize::from(mode))?;
            Some(ControllerMode {
                device: port.split(':').next().unwrap_or(&port).to_owned(),
                mode: mode.name.clone(),
                controller: controller.name().to_owned(),
                port,
            })
        })
        .collect()
}

/// Outlines a touched slider at `rect`, above what the widgets paint over
/// it (inactive scenes are dimmed) but within `ui`'s visible part.
pub(crate) fn outline_touched(ui: &Ui, rect: Rect) {
    let layer = LayerId::new(Order::Foreground, Id::new("touched controls"));
    ui.ctx()
        .layer_painter(layer)
        .with_clip_rect(ui.clip_rect())
        .rect_stroke(
            rect.expand(3.0),
            4.0,
            Stroke::new(3.0, CONTROLLER_COLOR),
            StrokeKind::Outside,
        );
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn the_first_state_lights_nothing_up_but_every_later_switch_does() {
        let mut highlight = ModeHighlight::default();
        let switch = |number| ModeSwitch {
            number,
            highlight: Some(MidiModeHighlight::SceneGrid),
        };
        highlight.follow(Some(switch(4)));
        assert!(!highlight.is_shown());
        highlight.follow(Some(switch(4)));
        assert!(!highlight.is_shown());
        highlight.follow(Some(switch(5)));
        assert!(highlight.is_shown());
    }

    #[test]
    fn the_border_runs_around_and_its_rest_shrinks() {
        let rect = Rect::from_min_size(pos2(0.0, 0.0), vec2(100.0, 50.0));
        let border = perimeter(rect, 0.0);
        assert!((path_length(&border) - 300.0).abs() < 0.01);
        assert_eq!(border.first(), border.last());
        let rest = path_from(&border, 75.0);
        assert_eq!(rest.first(), Some(&pos2(100.0, 25.0)));
        assert!((path_length(&rest) - 225.0).abs() < 0.01);
        assert!(path_from(&border, 301.0).is_empty());
    }

    #[test]
    fn the_first_switch_after_none_lights_up() {
        let mut highlight = ModeHighlight::default();
        highlight.follow(None);
        highlight.follow(Some(ModeSwitch {
            number: 1,
            highlight: Some(MidiModeHighlight::SceneSettings),
        }));
        assert!(highlight.is_shown());
    }
}