gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Files the definition editor loads: a photo of the lamp, an SVG to draw
//! pixels with, an SVG path to lay LEDs along. The file chooser's thread reads
//! and decodes them, so the editor only takes in what is ready.

use crate::canvas::images::{Decoded, decode_picture};
use crate::canvas::pictures;
use crate::fixture_definition_editor::draw_tools::Tool;
use crate::fixture_definition_editor::edit::{DefinitionEdit, Edit};
use crate::fixture_definition_editor::properties::new_photo;
use crate::fixture_definition_editor::shape::Shape;
use crate::fixture_definition_editor::state::EditorState;
use crate::fixture_definition_editor::svg_path::path_from_svg;
use crate::ui::file_pick::ChosenFile;
use egui::Context;
use gled_model::{asset::picture::new_picture_path, patching::definition::Model};
use std::path::PathBuf;

/// A file chosen for one definition. The chooser does not stop the window,
/// so another definition may be shown by the time the file is loaded.
pub(crate) struct PickedFile {
    /// The definition shown when the file was asked for.
    pub(crate) definition: Option<uuid::Uuid>,
    pub(crate) file: DefinitionFile,
}

/// A file chosen for the definition, loaded.
pub(crate) enum DefinitionFile {
    /// A picture of the lamp, to lie under its pixels, with where it goes
    /// in the asset repository.
    Photo {
        name: String,
        path: PathBuf,
        bytes: Vec<u8>,
        image: Decoded,
    },
    /// A drawing to draw pixels or the outline with.
    Model(Result<Model, String>),
    /// The longest path of a drawing, to lay LEDs along, and how many paths
    /// the drawing has.
    Path {
        name: String,
        shape: Result<(Shape, usize), String>,
    },
}

impl DefinitionFile {
    pub(crate) fn photo(file: ChosenFile) -> Self {
        let path = new_picture_path(&file.extension());
        let image = decode_picture(&path, &file.bytes);
        Self::Photo {
            name: file.name,
            path,
            bytes: file.bytes,
            image,
        }
    }

    pub(crate) fn model(file: ChosenFile) -> Self {
        Self::Model(load_model(&file))
    }

    pub(crate) fn path(file: ChosenFile) -> Self {
        let shape = file.text().and_then(path_from_svg);
        Self::Path {
            name: file.name,
            shape,
        }
    }
}

/// An SVG file as a model, drawn at the size the file gives it.
fn load_model(file: &ChosenFile) -> Result<Model, String> {
    let svg = file.text()?.to_owned();
    let size_m = gled_svg::ParsedSvg::parse(&svg)
        .map(|parsed| parsed.size_mm().map(|mm| mm / 1000.0))
        .map_err(|error| format!("{} is no SVG gled can draw: {error}", file.name))?;
    Ok(Model {
        name: file.stem(),
        size_m,
        svg,
    })
}

/// Takes in a file the chooser's thread has loaded for the definition
/// `edited`. What could not be loaded is shown to the user, and so is a file
/// chosen for another definition, which is left out.
pub(crate) fn apply_picked_file(
    ctx: &Context,
    edit: &mut Edit<'_>,
    state: &mut EditorState,
    edited: uuid::Uuid,
    picked: PickedFile,
) {
    if picked.definition != Some(edited) {
        state.message = Some("Left out a file chosen for another definition".to_string());
        return;
    }
    match picked.file {
        DefinitionFile::Photo {
            path,
            bytes,
            image: Ok(image),
            ..
        } => {
            let [width, height] = image.size;
            let aspect = width as f32 / height as f32;
            state.caches.images.insert(ctx, path.clone(), Ok(image));
            pictures::store(path.clone(), bytes);
            let chosen = new_photo(path, edit.value.size_m, Some(aspect));
            edit.change(state, |definition| definition.photo = Some(chosen));
        }
        DefinitionFile::Photo {
            name,
            image: Err(error),
            ..
        } => state.message = Some(format!("Could not load {name}: {error}")),
        DefinitionFile::Model(Ok(model)) => {
            edit.change(state, |definition| definition.models.push(model));
        }
        DefinitionFile::Model(Err(message)) => state.message = Some(message),
        DefinitionFile::Path {
            shape: Ok((shape, paths)),
            ..
        } => {
            state.draw.tool = Tool::Path;
            state.draw.shape = Some(shape);
            state.message =
                (paths > 1).then(|| format!("{paths} paths in the file; the longest is used"));
        }
        DefinitionFile::Path {
            name,
            shape: Err(error),
        } => state.message = Some(format!("{name}: {error}")),
    }
}

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

    fn model_for(definition: uuid::Uuid) -> PickedFile {
        PickedFile {
            definition: Some(definition),
            file: DefinitionFile::Model(Ok(Model::circle("led", 0.01))),
        }
    }

    /// Regression: the chooser does not stop the window, and a file chosen
    /// for one definition landed on whichever was shown when it arrived.
    #[test]
    fn a_file_chosen_for_another_definition_is_left_out() {
        let ctx = Context::default();
        let (asked, shown) = (uuid::Uuid::new_v4(), uuid::Uuid::new_v4());
        let mut definition = gled_model::patching::definition::FixtureDefinition::default();
        let mut undo = crate::canvas::undo::UndoStack::default();
        let mut edit = Edit::new(&mut definition, &mut undo);
        let mut state = EditorState::default();

        apply_picked_file(&ctx, &mut edit, &mut state, shown, model_for(asked));
        assert!(edit.value.models.is_empty());
        assert!(!edit.changed(), "the definition shown is not touched");
        assert!(state.message.is_some(), "and the user is told");

        apply_picked_file(&ctx, &mut edit, &mut state, shown, model_for(shown));
        assert_eq!(edit.value.models.len(), 1);
    }

    #[test]
    fn a_model_that_cannot_be_read_says_which_file() {
        let broken = ChosenFile {
            name: "model.svg".to_owned(),
            bytes: b"<not svg".to_vec(),
        };
        let Err(message) = load_model(&broken) else {
            panic!("it is no SVG");
        };
        assert!(message.contains("model.svg"), "{message}");
    }

    #[test]
    fn a_model_is_named_after_its_file() {
        let file = ChosenFile {
            name: "Bar.svg".to_owned(),
            bytes: br#"<svg xmlns="http://www.w3.org/2000/svg" width="10mm" height="5mm"/>"#
                .to_vec(),
        };
        let model = load_model(&file).expect("an SVG");
        assert_eq!(model.name, "Bar");
    }
}