gled-ui 2.33.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! The inspector of a chain's network output: where in the universes the
//! chain starts.

use crate::fixture_editor::FixtureWindow;
use crate::fixture_editor::state::PatchEdit;
use egui::{Color32, Grid, Ui};
use gled_model::patching::fixture::{
    CHANNELS_PER_LAMP, FixtureId, LAMPS_PER_UNIVERSE, MAX_UNIVERSE,
};
use gled_model::patching::topology::Port;

/// What a chain's network output reads as: the universe it starts on, and —
/// when the chain is pinned somewhere other than the start of that universe —
/// the DMX address too.
pub(crate) fn port_label(port: Port) -> String {
    format!("{}.{:03}", port.universe, port.channel + 1)
}

impl FixtureWindow {
    /// The chain's network output: which universe it starts on, and — when the
    /// chain does not start at the beginning of that universe — where in it.
    pub(super) fn show_port_inspector(
        &mut self,
        ui: &mut Ui,
        edit: &mut PatchEdit<'_>,
        head: FixtureId,
    ) {
        let name = edit
            .value
            .fixture(head)
            .map(|fixture| fixture.name.clone())
            .unwrap_or_default();
        let Some(port) = edit.value.topology.ports.get(&head).copied() else {
            ui.label("No network output");
            return;
        };

        ui.heading("Network output");
        ui.label(
            egui::RichText::new(format!("Chain starting at {name}")).color(Color32::from_gray(160)),
        );
        Grid::new("port inspector")
            .num_columns(2)
            .spacing([8.0, 4.0])
            .show(ui, |ui| {
                ui.label("Universe");
                let mut universe = port.universe;
                // a chain past the highest universe would get no address
                let response = ui
                    .add(egui::DragValue::new(&mut universe).range(0..=MAX_UNIVERSE))
                    .on_hover_text("Up to the highest Art-Net universe");
                edit.tracked(&response, |patch| {
                    if let Some(port) = patch.topology.ports.get_mut(&head) {
                        port.universe = universe;
                    }
                });
                ui.end_row();

                // The field steps in lamps, shown as the DMX address each
                // starts on: 1, 4, 7, …
                ui.label("DMX start");
                let mut lamp = port.channel / CHANNELS_PER_LAMP;
                let channel = port.channel;
                let response = ui
                    .horizontal(|ui| {
                        let response = ui
                            .add(
                                egui::DragValue::new(&mut lamp)
                                    .range(0..=LAMPS_PER_UNIVERSE - 1)
                                    .speed(0.25)
                                    .custom_formatter(move |lamp, _| {
                                        shown_dmx_start(channel, lamp as u16)
                                    })
                                    .custom_parser(typed_dmx_start),
                            )
                            .on_hover_text(
                                "First channel of the chain, inside its universe. Pixels \
                                 are RGB triples, so a chain starts on 1, 4, 7, …",
                            );
                        if !channel.is_multiple_of(CHANNELS_PER_LAMP) {
                            ui.colored_label(super::WARNING, "⚠").on_hover_text(
                                "Not the start of an RGB triple: each pixel mixes its \
                                     colour with its neighbour's. Any change snaps it to one.",
                            );
                        }
                        response
                    })
                    .inner;
                edit.tracked(&response, |patch| {
                    if let Some(port) = patch.topology.ports.get_mut(&head) {
                        port.channel = lamp * CHANNELS_PER_LAMP;
                    }
                });
                ui.end_row();
            });
        ui.label(
            egui::RichText::new("A chain that runs past the universe continues in the next one.")
                .small()
                .color(Color32::from_gray(140)),
        );
    }
}

/// The DMX address lamp `lamp` starts on, counted from one. A chain whose
/// start `channel` lies inside that lamp — from a file written before starts
/// were held to whole lamps — shows where it really starts, until it is
/// changed.
fn shown_dmx_start(channel: u16, lamp: u16) -> String {
    let start = if channel / CHANNELS_PER_LAMP == lamp {
        channel
    } else {
        lamp * CHANNELS_PER_LAMP
    };
    (start + 1).to_string()
}

/// The lamp a typed DMX address falls in: the triple holding it.
fn typed_dmx_start(text: &str) -> Option<f64> {
    let address = text.trim().parse::<f64>().ok()?;
    Some(
        ((address - 1.0) / f64::from(CHANNELS_PER_LAMP))
            .floor()
            .max(0.0),
    )
}

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

    /// The network output carries the addressing, so it reads as the full
    /// address the chain starts at, universe and DMX start together.
    #[test]
    fn a_port_reads_as_its_universe_and_start() {
        assert_eq!(
            port_label(Port {
                universe: 3,
                channel: 0
            }),
            "3.001"
        );
        assert_eq!(
            port_label(Port {
                universe: 3,
                channel: 49
            }),
            "3.050"
        );
        assert_eq!(
            port_label(Port {
                universe: 0,
                channel: 1
            }),
            "0.002"
        );
    }

    /// A chain starts on an RGB triple: 1, 4, 7, … A typed address inside a
    /// triple lands on its start.
    #[test]
    fn a_dmx_start_snaps_to_whole_lamps() {
        let typed = |text| typed_dmx_start(text).map(|lamp| lamp as u16);
        assert_eq!(typed("1"), Some(0));
        assert_eq!(typed("6"), Some(1));
        assert_eq!(typed(" 508"), Some(169));
        assert_eq!(typed("lots"), None);

        assert_eq!(shown_dmx_start(3, 1), "4");
        assert_eq!(shown_dmx_start(3, 2), "7");
    }

    /// A start from an older file that lies inside a triple is shown as it
    /// is, not as the triple it would snap to.
    #[test]
    fn a_start_inside_a_triple_shows_where_it_is() {
        assert_eq!(shown_dmx_start(5, 1), "6");
        assert_eq!(shown_dmx_start(5, 2), "7");
    }
}