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};
pub(crate) const CONTROLLER_COLOR: Color32 = Color32::from_rgb(0, 170, 255);
const HIGHLIGHT_TIME: Duration = Duration::from_secs(3);
const RETRACT_TIME: Duration = Duration::from_millis(800);
const CORNER_RADIUS: f32 = 6.0;
#[derive(Debug, Default)]
pub(crate) struct ModeHighlight {
seen: Option<Option<u64>>,
shown: Option<(MidiModeHighlight, Instant)>,
}
impl ModeHighlight {
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()));
}
}
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;
}
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
}
}
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()
}
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")
}
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));
}
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))
}
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,
},
)
})
}
pub(crate) struct ControllerStatus {
pub(crate) device: String,
pub(crate) port: String,
pub(crate) controller: Option<String>,
pub(crate) mode: Option<String>,
}
pub(crate) fn controller_statuses(
state: &EngineState,
collections: &Collections,
local_ports: &BTreeSet<String>,
) -> Vec<ControllerStatus> {
let project = state.project.as_deref();
let ports: BTreeSet<String> = state
.midi_inputs
.iter()
.chain(local_ports)
.map(|port| normalize_controller_key(port))
.collect();
let statuses: Vec<ControllerStatus> = ports
.into_iter()
.map(|port| {
let controller = project
.and_then(|project| controller_for_port(project, &port, collections))
.and_then(|id| Asset::<MidiController>::get(id, collections));
let mode = controller.as_ref().and_then(|controller| {
let mode = state.midi_modes.get(&port).copied().unwrap_or(0);
controller
.data
.mapping
.modes
.get(usize::from(mode))
.map(|mode| mode.name.clone())
});
ControllerStatus {
device: port.split(':').next().unwrap_or(&port).to_owned(),
controller: controller.map(|controller| controller.name().to_owned()),
mode,
port,
}
})
.collect();
one_entry_per_device(statuses)
}
fn one_entry_per_device(statuses: Vec<ControllerStatus>) -> Vec<ControllerStatus> {
let with_controller: BTreeSet<String> = statuses
.iter()
.filter(|status| status.controller.is_some())
.map(|status| status.device.clone())
.collect();
let mut shown_without = BTreeSet::new();
statuses
.into_iter()
.filter(|status| {
status.controller.is_some()
|| (!with_controller.contains(&status.device)
&& shown_without.insert(status.device.clone()))
})
.collect()
}
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 a_device_with_several_ports_shows_once() {
let status = |device: &str, port: &str, controller: bool| ControllerStatus {
device: device.to_owned(),
port: port.to_owned(),
controller: controller.then(|| "Controller".to_owned()),
mode: None,
};
let shown: Vec<String> = one_entry_per_device(vec![
status("APC40", "APC40:APC40 MIDI 1", false),
status("APC40", "APC40:APC40 MIDI 2", false),
status("P1-X", "P1-X:P1-X MIDI 1", true),
status("P1-X", "P1-X:P1-X MIDI 2", false),
])
.into_iter()
.map(|status| status.port)
.collect();
assert_eq!(shown, ["APC40:APC40 MIDI 1", "P1-X:P1-X MIDI 1"]);
}
#[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());
}
}