gled-ui 2.32.0

gled's egui interface, shared by the desktop app and the browser
Documentation
//! The patch editor's inspector of the selected fixtures.

use crate::canvas::multi_value::{MultiValue, shown};
use crate::fixture_editor::FixtureWindow;
use crate::fixture_editor::state::PatchEdit;
use egui::{ComboBox, DragValue, Frame, Grid, Response, ScrollArea, Ui};
use gled_model::collection::Collections;
use gled_model::patching::Patch;
use gled_model::patching::fixture::FixtureId;
use gled_model::patching::layout::LayoutRef;

impl FixtureWindow {
    /// Inspects the selected fixtures, or the selected network output.
    /// Attributes shared by the whole selection can be set at once; an
    /// attribute whose values disagree shows "-" until it is set.
    pub(super) fn show_inspector(
        &mut self,
        ui: &mut Ui,
        edit: &mut PatchEdit<'_>,
        collections: &Collections,
    ) {
        if let Some(head) = self.state.selected_port {
            self.show_port_inspector(ui, edit, head);
            return;
        }
        let selected: Vec<FixtureId> = self
            .state
            .selected_fixtures
            .iter()
            .copied()
            .filter(|id| edit.value.topology.fixtures.contains_key(id))
            .collect();
        let (layout, layout_mode) = (self.state.selection, self.state.selected_mode);

        ScrollArea::vertical()
            .id_salt("fixture inspector")
            .auto_shrink([false; 2])
            .show(ui, |ui| {
                if selected.is_empty() {
                    ui.label("No selection");
                    return;
                }
                // The view is what the patch says now; what is set in it is
                // applied afterwards, as one undo step per change.
                let view =
                    InspectorView::of(edit.value, collections, layout, layout_mode, &selected);
                let picked = Frame::new().show(ui, |ui| view.show(ui)).inner;
                picked.apply(edit, collections, layout, layout_mode, &selected);
            });
    }
}

/// What the inspector's widgets set this frame, applied once the view has
/// been drawn.
#[derive(Debug)]
struct Picked {
    mode: Option<String>,
    /// The rotation field, and the rotation in it, in degrees, for every
    /// selected fixture.
    rotation: Option<(Response, f32)>,
}

impl Picked {
    /// Sets what was picked on every fixture in `selected`.
    fn apply(
        self,
        edit: &mut PatchEdit<'_>,
        collections: &Collections,
        layout: LayoutRef,
        layout_mode: usize,
        selected: &[FixtureId],
    ) {
        if let Some((response, degrees)) = self.rotation {
            edit.tracked(&response, |patch| {
                if let Some(layout) = patch.layout_mut(layout) {
                    for id in selected {
                        if let Some(position) = layout.members.get_mut(id) {
                            position.angle = degrees.to_radians();
                        }
                    }
                }
            });
        }
        if let Some(name) = self.mode {
            edit.apply(|patch| {
                for id in selected {
                    let index = patch.definition(*id, collections).and_then(|definition| {
                        definition
                            .data
                            .mode_names()
                            .iter()
                            .position(|mode| *mode == name)
                    });
                    if let Some(index) = index {
                        patch.set_fixture_mode(layout, layout_mode, *id, index);
                    }
                }
            });
        }
    }
}

/// What the inspector shows of the selection, read from the patch before
/// anything is drawn, so the patch is free to change once it has been.
struct InspectorView {
    /// The fixture's name, or how many are selected.
    heading: String,
    address: Option<MultiValue<String>>,
    definition: Option<MultiValue<String>>,
    /// Modes every selected fixture's definition offers.
    modes: Vec<String>,
    mode: Option<MultiValue<String>>,
    /// Each selected fixture's rotation in the layout, in degrees.
    angles: Vec<f32>,
}

impl InspectorView {
    fn of(
        patch: &Patch,
        collections: &Collections,
        layout: LayoutRef,
        layout_mode: usize,
        selected: &[FixtureId],
    ) -> Self {
        let heading = match selected {
            [id] => patch
                .fixture(*id)
                .map(|fixture| fixture.name.clone())
                .unwrap_or_default(),
            _ => format!("{} fixtures selected", selected.len()),
        };
        let addresses = patch.addresses(collections);
        let address = MultiValue::collect(
            selected
                .iter()
                .map(|id| addresses.get(id).map(|a| a.to_string()).unwrap_or_default()),
        );
        let definition = MultiValue::collect(selected.iter().map(|id| {
            patch
                .definition(*id, collections)
                .map(|definition| definition.name().to_string())
                .unwrap_or_else(|| "none".to_string())
        }));
        let mode = MultiValue::collect(selected.iter().map(|id| {
            patch
                .fixture_mode(layout, layout_mode, *id, collections)
                .and_then(|index| {
                    let definition = patch.definition(*id, collections)?;
                    Some(definition.data.mode(index)?.name.clone())
                })
                .unwrap_or_default()
        }));
        let angles = patch
            .layout(layout)
            .map(|layout| {
                selected
                    .iter()
                    .filter_map(|id| layout.members.get(id))
                    .map(|position| shown_degrees(position.angle))
                    .collect()
            })
            .unwrap_or_default();
        Self {
            heading,
            address,
            definition,
            modes: common_modes(patch, collections, selected),
            mode,
            angles,
        }
    }

    fn show(&self, ui: &mut Ui) -> Picked {
        ui.vertical(|ui| {
            ui.heading(&self.heading);

            Grid::new("fixture inspector")
                .num_columns(2)
                .spacing([8.0, 4.0])
                .show(ui, |ui| {
                    ui.label("Definition");
                    ui.label(shown(&self.definition));
                    ui.end_row();

                    ui.label("Address");
                    ui.label(shown(&self.address))
                        .on_hover_text("Universe.channel, derived from the topology");
                    ui.end_row();

                    ui.label("Mode");
                    let mode = self.mode_combo(ui);
                    ui.end_row();

                    ui.label("Rotation");
                    let rotation = self.rotation(ui);
                    ui.end_row();
                    Picked { mode, rotation }
                })
                .inner
        })
        .inner
    }

    /// The selection's rotation in this layout, in degrees; "-" while the
    /// fixtures differ. Returns the field and the rotation in it.
    fn rotation(&self, ui: &mut Ui) -> Option<(Response, f32)> {
        let first = *self.angles.first()?;
        let differs = self.angles.iter().any(|angle| (angle - first).abs() > 0.05);
        let mut degrees = first;
        let mut value = DragValue::new(&mut degrees)
            .speed(1.0)
            .range(-180.0..=180.0)
            .max_decimals(1)
            .suffix("°");
        if differs {
            value = value.custom_formatter(|_, _| "-".to_string());
        }
        let response = ui
            .add(value)
            .on_hover_text("Clockwise; the handle above the selection turns it too");
        Some((response, degrees))
    }

    fn mode_combo(&self, ui: &mut Ui) -> Option<String> {
        let mut picked: Option<String> = None;
        // A selection sharing no definition has no modes to offer.
        ui.add_enabled_ui(!self.modes.is_empty(), |ui| {
            ComboBox::from_id_salt("fixture mode")
                .selected_text(shown(&self.mode))
                .show_ui(ui, |ui| {
                    for name in &self.modes {
                        let selected = self.mode.as_ref().and_then(MultiValue::same) == Some(name);
                        if ui.selectable_label(selected, name).clicked() {
                            picked = Some(name.clone());
                        }
                    }
                });
        });

        picked
    }
}

/// Only modes every selected fixture's definition offers are listed — an
/// attribute that is not shared is not shown at all.
fn common_modes(patch: &Patch, collections: &Collections, selected: &[FixtureId]) -> Vec<String> {
    let mut common: Option<Vec<String>> = None;
    for id in selected {
        let names: Vec<String> = patch
            .definition(*id, collections)
            .map(|definition| {
                definition
                    .data
                    .mode_names()
                    .into_iter()
                    .map(str::to_string)
                    .collect()
            })
            .unwrap_or_default();
        common = Some(match common {
            None => names,
            Some(existing) => existing
                .into_iter()
                .filter(|name| names.contains(name))
                .collect(),
        });
    }
    common.unwrap_or_default()
}

/// An angle in radians as the inspector shows it: degrees from -180 to 180.
fn shown_degrees(angle: f32) -> f32 {
    let degrees = angle.to_degrees().rem_euclid(360.0);
    if degrees > 180.0 {
        degrees - 360.0
    } else {
        degrees
    }
}

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

    #[test]
    fn rotations_are_shown_between_minus_and_plus_half_a_turn() {
        assert!((shown_degrees(270f32.to_radians()) + 90.0).abs() < 1e-3);
        assert!((shown_degrees(-450f32.to_radians()) + 90.0).abs() < 1e-3);
        assert!((shown_degrees(30f32.to_radians()) - 30.0).abs() < 1e-3);
    }
}