gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! The fixture as a whole: its size, how a layout may distort it, and the
//! photo drawn under its pixels.

use crate::fixture_definition_editor::edit::{DefinitionEdit, Edit};
use crate::fixture_definition_editor::picked_file::DefinitionFile;
use crate::fixture_definition_editor::state::EditorState;
use crate::fixture_definition_editor::units::millimetres;
use crate::ui::file_pick;
use egui::{Button, ComboBox, Grid, Image, Slider, Ui, vec2};
use gled_model::patching::definition::{DEFAULT_PHOTO_OPACITY, FixtureDefinition, Photo, Scaling};
use std::path::PathBuf;
use strum::{EnumMessage, IntoEnumIterator};

/// The photo's lengths the panel edits: label, smallest value in metres, and
/// where the value lives.
const PHOTO_LENGTHS: [(&str, f32, fn(&mut Photo) -> &mut f32); 4] = [
    ("Width", 0.0, |photo| &mut photo.size_m[0]),
    ("Height", 0.0, |photo| &mut photo.size_m[1]),
    ("X", -MAX_LENGTH_M, |photo| &mut photo.offset_m[0]),
    ("Y", -MAX_LENGTH_M, |photo| &mut photo.offset_m[1]),
];

/// Longest a fixture or its photo can be made here, in metres.
const MAX_LENGTH_M: f32 = 100.0;

pub(crate) fn show(ui: &mut Ui, edit: &mut Edit<'_>, state: &mut EditorState) {
    ui.label(format!(
        "{} pixels · {} channels",
        edit.value.pixels.len(),
        edit.value.channel_count()
    ));
    Grid::new("fixture properties")
        .num_columns(2)
        .show(ui, |ui| {
            for (axis, label) in ["Width", "Height", "Depth"].into_iter().enumerate() {
                ui.label(label);
                let mut metres = edit.value.size_m[axis];
                let response = ui.add(millimetres(&mut metres).range(0.0..=MAX_LENGTH_M));
                edit.tracked(&response, |definition| definition.size_m[axis] = metres);
                ui.end_row();
            }

            ui.label("Distortion");
            let mut scaling = edit.value.scaling;
            ComboBox::from_id_salt("fixture scaling")
                .selected_text(scaling.to_string())
                .show_ui(ui, |ui| {
                    for candidate in Scaling::iter() {
                        ui.selectable_value(&mut scaling, candidate, candidate.to_string())
                            .on_hover_text(candidate.get_message().unwrap_or_default());
                    }
                });
            if scaling != edit.value.scaling {
                edit.change(state, |definition| definition.scaling = scaling);
            }
            ui.end_row();
        });

    ui.horizontal(|ui| {
        if ui.button("Fit size to pixels").clicked() {
            edit.change(state, |definition| definition.fit_size());
        }
        let has_photo = edit.value.photo.is_some();
        if ui
            .add_enabled(has_photo, Button::new("Fit size to photo"))
            .on_hover_text(
                "The fixture's frame, which the patch editor selects and outlines, covers the whole photo",
            )
            .clicked()
        {
            edit.change(state, |definition| definition.fit_size_to_photo());
        }
        if ui
            .button("Centre pixels")
            .on_hover_text("A layout places and turns the fixture about its origin; the photo moves along")
            .clicked()
        {
            edit.change(state, |definition| definition.centre_pixels());
        }
    });

    ui.separator();
    photo(ui, edit, state);
}

/// The photo: choosing and clearing it, how strongly it shows, and where it
/// lies under the pixels.
fn photo(ui: &mut Ui, edit: &mut Edit<'_>, state: &mut EditorState) {
    ui.label("Photo");
    let photo = edit.value.photo.clone();
    ui.horizontal(|ui| {
        if let Some(texture) = photo
            .as_ref()
            .and_then(|photo| state.caches.images.texture(ui.ctx(), &photo.file))
        {
            ui.add(
                Image::new(&texture)
                    .fit_to_exact_size(vec2(96.0, 54.0))
                    .corner_radius(3.0),
            );
        }
        choose_photo(ui, state);
        if photo.is_some() && ui.button("Clear").clicked() {
            edit.change(state, |definition| definition.photo = None);
        }
    });
    let Some(mut photo) = photo else {
        return;
    };

    Grid::new("fixture photo").num_columns(2).show(ui, |ui| {
        ui.label("Opacity");
        let mut percent = photo.opacity * 100.0;
        let response = ui
            .add(
                Slider::new(&mut percent, 0.0..=100.0)
                    .suffix(" %")
                    .max_decimals(0),
            )
            .on_hover_text("Faint, the photo shows the lamp without hiding its pixels");
        edit.tracked(&response, |definition| {
            with_photo(definition, |photo| photo.opacity = percent / 100.0)
        });
        ui.end_row();

        for (label, min_m, length) in PHOTO_LENGTHS {
            ui.label(label);
            let mut metres = *length(&mut photo);
            let response = ui.add(millimetres(&mut metres).range(min_m..=MAX_LENGTH_M));
            edit.tracked(&response, |definition| {
                with_photo(definition, |photo| *length(photo) = metres)
            });
            ui.end_row();
        }
    });
}

fn with_photo(definition: &mut FixtureDefinition, change: impl FnOnce(&mut Photo)) {
    if let Some(photo) = &mut definition.photo {
        change(photo);
    }
}

/// A newly chosen photo: centred on the origin, as large as fits the fixture
/// without distorting it, faint. A fixture without a size gets a photo a
/// metre wide.
pub(crate) fn new_photo(file: PathBuf, fixture_size_m: [f32; 3], aspect: Option<f32>) -> Photo {
    let aspect = aspect
        .filter(|aspect| aspect.is_finite() && *aspect > 0.0)
        .unwrap_or(1.0);
    let [width, height] = [fixture_size_m[0].abs(), fixture_size_m[1].abs()];
    let fit_width = if width > 0.0 { width } else { 1.0 };
    let fit_height = if height > 0.0 { height } else { f32::INFINITY };
    let width = fit_width.min(fit_height * aspect);
    Photo {
        file,
        size_m: [width, width / aspect],
        offset_m: [0.0, 0.0],
        opacity: DEFAULT_PHOTO_OPACITY,
    }
}

/// Asks for a picture of the lamp, bitmap or SVG.
fn choose_photo(ui: &mut Ui, state: &mut EditorState) {
    if ui
        .add_enabled(!state.file_pick.is_open(), Button::new("Choose…"))
        .clicked()
    {
        state.ask_file(ui.ctx(), file_pick::PHOTOS, DefinitionFile::photo);
    }
}

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

    #[test]
    fn a_new_photo_fits_the_fixture_undistorted() {
        let photo = new_photo("lamp.png".into(), [0.6, 0.6, 0.05], Some(2.0));
        assert_eq!(photo.size_m, [0.6, 0.3]);
        assert_eq!(photo.opacity, DEFAULT_PHOTO_OPACITY);
    }

    #[test]
    fn a_photo_for_a_flat_fixture_takes_its_width() {
        let photo = new_photo("bar.png".into(), [1.2, 0.0, 0.0], Some(4.0));
        assert_eq!(photo.size_m, [1.2, 0.3]);
    }

    #[test]
    fn a_photo_for_a_fixture_without_size_is_a_metre_wide() {
        let photo = new_photo("lamp.png".into(), [0.0; 3], None);
        assert_eq!(photo.size_m, [1.0, 1.0]);
    }
}