gled-ui 2.33.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Beat timing as the user interface shows it: the engine owns Link and the
//! tempo; this mirrors its state and turns the timing buttons into actions.

use egui::{Button, Color32, CornerRadius, Slider, Stroke, TextFormat, Ui, Vec2, text::LayoutJob};
use gled_protocol::state::ClientInput;
use web_time::Instant;

use gled_protocol::action::UiAction;

use crate::app::timing::BeatState;

/// The color of Ableton Link in the interface.
pub(crate) const LINK_ACTIVE_COLOR: Color32 = Color32::from_rgb(41, 116, 145);

#[derive(Debug, Default)]
pub(crate) struct ClientTiming {
    beat: BeatState,
    /// When `beat` arrived, to move it on between the engine's reports.
    received_at: Option<Instant>,
    tap_count: usize,
    /// The BPM slider's value while it is being dragged.
    slider: Option<f32>,
}

impl ClientTiming {
    /// Takes the beat timing the engine published.
    pub(crate) fn update(&mut self, beat: BeatState, tap_count: usize) {
        if beat != self.beat {
            self.beat = beat;
            self.received_at = Some(Instant::now());
        }
        self.tap_count = tap_count;
    }

    /// The beat now: the engine's last report moved on by the time since,
    /// for what runs every frame between the reports, such as controller
    /// lights that blink with the beat.
    pub(crate) fn beat_progression_now(&self) -> f32 {
        let elapsed = self
            .received_at
            .map_or(0.0, |received_at| received_at.elapsed().as_secs_f32());
        self.beat.beat_progression + elapsed * self.beat.beats_per_minute / 60.0
    }

    pub(crate) fn beat_progression(&self) -> f32 {
        self.beat.beat_progression
    }

    pub(crate) fn beats_per_minute(&self) -> f32 {
        self.beat.beats_per_minute
    }

    /// Frame rate of the render thread, `None` until its first report.
    pub(crate) fn framerate(&self) -> Option<f32> {
        (self.beat.framerate > 0.0).then_some(self.beat.framerate)
    }

    pub(crate) fn beat_state(&self) -> BeatState {
        self.beat
    }

    /// The tempo slider. Changes go to the engine while it is dragged.
    pub(crate) fn bpm_slider(&mut self, ui: &mut Ui, enabled: bool) {
        let mut value = self.slider.unwrap_or(self.beat.beats_per_minute);
        let response = ui.add_enabled(
            enabled,
            Slider::new(&mut value, 20.0..=999.0).custom_formatter(|n, _| format!("{n:.1} bpm")),
        );
        if response.changed() {
            ClientInput::SetBeatsPerMinute(value).enqueue();
        }
        self.slider = (response.dragged() || response.has_focus()).then_some(value);
    }

    pub(crate) fn half_button(&self, ui: &mut Ui, ableton_link_read_only: bool) {
        if ui
            .add_enabled(!ableton_link_read_only, Button::new("x½"))
            .clicked()
        {
            UiAction::SpeedMultiply(0.5).enqueue();
        }
    }

    pub(crate) fn double_button(&self, ui: &mut Ui, ableton_link_read_only: bool) {
        if ui
            .add_enabled(!ableton_link_read_only, Button::new("x2"))
            .clicked()
        {
            UiAction::SpeedMultiply(2.0).enqueue();
        }
    }

    pub(crate) fn tap_button(
        &self,
        ui: &mut Ui,
        menu_button_size: Vec2,
        ableton_link_read_only: bool,
    ) {
        ui.scope(|ui| {
            ui.style_mut().visuals.widgets.inactive.bg_stroke =
                ui.style().visuals.widgets.noninteractive.bg_stroke;

            let underlined = TextFormat {
                underline: Stroke::new(1.0, Color32::GRAY),
                ..Default::default()
            };
            let mut tap_text = LayoutJob::default();
            tap_text.append("T", 0.0, underlined);
            tap_text.append(
                &format!(
                    "ap{}",
                    match (self.tap_count, self.tap_count % 4) {
                        (0, _) => "",
                        (_, 0) => "/",
                        (_, 1) => "–",
                        (_, 2) => "\\",
                        _ => "|",
                    }
                ),
                0.0,
                TextFormat::default(),
            );
            let response = ui.add_enabled(
                !ableton_link_read_only,
                Button::new(tap_text).min_size(menu_button_size),
            );

            let mut alpha = None;
            let bar_progression = self.beat.beat_progression % 1.0;
            if bar_progression < 0.10 {
                alpha = Some(30.0);
            } else if bar_progression < 0.20 {
                alpha = Some(20.0 - ((bar_progression - 0.1) * 200.0));
            } else if bar_progression > 0.9 {
                alpha = Some((bar_progression - 0.9) * 200.0);
            }
            if let Some(alpha) = alpha {
                ui.painter().rect_filled(
                    match self.beat_flank() {
                        0 =>
                        // top left
                        {
                            response
                                .rect
                                .split_left_right_at_fraction(0.5)
                                .0
                                .split_top_bottom_at_fraction(0.5)
                                .0
                        }
                        1 =>
                        // top right
                        {
                            response
                                .rect
                                .split_left_right_at_fraction(0.5)
                                .1
                                .split_top_bottom_at_fraction(0.5)
                                .0
                        }
                        2 =>
                        // bottom left
                        {
                            response
                                .rect
                                .split_left_right_at_fraction(0.5)
                                .0
                                .split_top_bottom_at_fraction(0.5)
                                .1
                        }
                        3 =>
                        // bottom right
                        {
                            response
                                .rect
                                .split_left_right_at_fraction(0.5)
                                .1
                                .split_top_bottom_at_fraction(0.5)
                                .1
                        }
                        _ => unreachable!(),
                    }
                    .shrink(1.0),
                    CornerRadius::default(),
                    Color32::from_white_alpha(alpha as u8),
                );
            }

            if response.clicked() {
                UiAction::Tap.enqueue();
            }
        });
    }

    pub(crate) fn beat_flank(&self) -> u8 {
        match self.beat.beat_progression % 4.0 {
            0.5..1.5 => 1,
            1.5..2.5 => 2,
            2.5..3.5 => 3,
            _ => 0,
        }
    }
}