gled-ui 2.31.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_image};
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 egui::Context;
use gled_model::patching::definition::Model;
use std::path::{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.
    Photo { path: PathBuf, 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 {
        path: PathBuf,
        shape: Result<(Shape, usize), String>,
    },
}

impl DefinitionFile {
    pub(crate) fn photo(path: PathBuf) -> Self {
        let image = decode_image(&path);
        Self::Photo { path, image }
    }

    pub(crate) fn model(path: PathBuf) -> Self {
        Self::Model(load_model(&path))
    }

    pub(crate) fn path(path: PathBuf) -> Self {
        let shape = std::fs::read_to_string(&path)
            .map_err(|error| error.to_string())
            .and_then(|svg| path_from_svg(&svg));
        Self::Path { path, shape }
    }
}

/// An SVG file as a model, drawn at the size the file gives it.
fn load_model(path: &Path) -> Result<Model, String> {
    let svg = std::fs::read_to_string(path)
        .map_err(|error| format!("Could not read {}: {error}", path.display()))?;
    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}", path.display()))?;
    let name = path.file_stem().map_or("model".to_string(), |stem| {
        stem.to_string_lossy().into_owned()
    });
    Ok(Model { name, 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,
            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));
            let chosen = new_photo(path, edit.value.size_m, Some(aspect));
            edit.change(state, |definition| definition.photo = Some(chosen));
        }
        DefinitionFile::Photo {
            path,
            image: Err(error),
        } => state.message = Some(format!("Could not load {}: {error}", path.display())),
        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 {
            path,
            shape: Err(error),
        } => state.message = Some(format!("{}: {error}", path.display())),
    }
}

#[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 Err(message) = load_model(Path::new("/nonexistent/gled/model.svg")) else {
            panic!("there is no such file");
        };
        assert!(message.contains("/nonexistent/gled/model.svg"), "{message}");
    }
}