gled-ui 2.31.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Forms for laying out many pixels at once. The geometry is the model's
//! (`gled_model::patching::definition::generators`); this asks for its
//! parameters, in millimetres, and previews the result on the canvas.

mod form;

use crate::fixture_definition_editor::edit::{DefinitionEdit, Edit};
use crate::fixture_definition_editor::state::EditorState;
use egui::{Id, Window};
use form::form;
use gled_model::patching::definition::generators::{
    Corner, GridOrder, GridSpec, PathSpec, PixelTemplate, RingSpec, grid, path, ring, single,
};
use gled_model::patching::definition::{ColorOrder, Model, Pixel};
use strum::{Display, EnumIter};

/// The size of the LED model a definition gets when its first pixels are
/// generated or drawn and it has no model yet, in metres.
const FIRST_LED_M: f32 = 0.01;
/// Most LEDs a form or a drawn shape adds at once.
pub(crate) const MAX_LEDS: usize = 4096;
/// What new pixels are called, followed by their number, until the form says
/// otherwise.
pub(crate) const DEFAULT_PIXEL_PREFIX: &str = "pixel-";

/// What the Add menu offers. It reads as its menu entry; an ellipsis marks
/// the ones that open a form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumIter)]
pub(crate) enum GeneratorKind {
    Pixel,
    #[strum(serialize = "Line…")]
    Line,
    #[strum(serialize = "Grid…")]
    Grid,
    #[strum(serialize = "Ring…")]
    Ring,
}

/// The last values each form was used with, so the next pixels continue
/// where the last ones left off.
#[derive(Debug, Clone)]
pub(crate) struct GeneratorParams {
    prefix: String,
    order: ColorOrder,
    pixel_at_m: [f32; 2],
    line_count: usize,
    line_by_pitch: bool,
    line_from_m: [f32; 2],
    line_to_m: [f32; 2],
    line_angle_deg: f32,
    line_pitch_m: f32,
    line_inset: bool,
    grid: GridSpec,
    ring: RingSpec,
}

impl Default for GeneratorParams {
    fn default() -> Self {
        Self {
            prefix: DEFAULT_PIXEL_PREFIX.to_string(),
            order: ColorOrder::Rgb,
            pixel_at_m: [0.0, 0.0],
            line_count: 10,
            line_by_pitch: false,
            line_from_m: [-0.5, 0.0],
            line_to_m: [0.5, 0.0],
            line_angle_deg: 0.0,
            line_pitch_m: 0.1,
            line_inset: true,
            grid: GridSpec {
                columns: 8,
                rows: 8,
                pitch_m: [0.03, 0.03],
                centre_m: [0.0, 0.0],
                order: GridOrder::SerpentineRows,
                start: Corner::TopLeft,
            },
            ring: RingSpec {
                centre_m: [0.0, 0.0],
                radius_m: 0.2,
                count: 12,
                start_deg: 0.0,
                clockwise: true,
                along_ring: true,
            },
        }
    }
}

impl GeneratorParams {
    /// The grid with no more rows than [`MAX_LEDS`] leaves room for: two
    /// counts that are each in range would otherwise ask for millions of
    /// pixels, laid out anew every frame the form is open.
    fn capped_grid(&self) -> GridSpec {
        GridSpec {
            rows: self.grid.rows.min(max_rows(self.grid.columns)),
            ..self.grid
        }
    }

    /// The pixels the form for `kind` makes right now.
    fn pixels(&self, kind: GeneratorKind, template: &PixelTemplate) -> Vec<Pixel> {
        match kind {
            GeneratorKind::Pixel => single(template, self.pixel_at_m),
            GeneratorKind::Line => {
                // by pitch the line ends where its last pixel sits
                let (to_m, inset_ends) = if self.line_by_pitch {
                    let (sin, cos) = self.line_angle_deg.to_radians().sin_cos();
                    let span = self.line_pitch_m * self.line_count.saturating_sub(1) as f32;
                    let from = self.line_from_m;
                    ([from[0] + cos * span, from[1] + sin * span], false)
                } else {
                    (self.line_to_m, self.line_inset)
                };
                let spec = PathSpec {
                    points_m: vec![self.line_from_m, to_m],
                    closed: false,
                    count: self.line_count,
                    inset_ends,
                };
                path(template, &spec)
            }
            GeneratorKind::Grid => grid(template, &self.capped_grid()),
            GeneratorKind::Ring => ring(template, &self.ring),
        }
    }
}

/// How new pixels are made: named and ordered as the form says, numbered on
/// from `next_number`, drawn with the current model.
pub(crate) fn template(state: &EditorState, next_number: usize) -> PixelTemplate {
    PixelTemplate {
        prefix: state.params.prefix.clone(),
        first_number: next_number,
        model: state.current_model,
        order: state.params.order,
    }
}

/// What the open form would add, for the canvas to preview.
pub(crate) fn preview(state: &EditorState, next_number: usize) -> Vec<Pixel> {
    state
        .generator
        .map(|kind| state.params.pixels(kind, &template(state, next_number)))
        .unwrap_or_default()
}

/// Adds a single pixel at once; the other kinds open their form.
pub(crate) fn start(
    kind: GeneratorKind,
    edit: &mut Edit<'_>,
    state: &mut EditorState,
    at_m: [f32; 2],
) {
    if kind == GeneratorKind::Pixel {
        state.params.pixel_at_m = at_m;
        add(GeneratorKind::Pixel, edit, state);
    } else {
        state.generator = Some(kind);
    }
}

/// Appends what `kind` makes and selects it.
fn add(kind: GeneratorKind, edit: &mut Edit<'_>, state: &mut EditorState) {
    let params = state.params.clone();
    add_pixels(edit, state, |template| params.pixels(kind, template));
}

/// Appends the pixels `make` lays out from the [`template`], as one undo
/// step, and selects them. A definition without a model gets an LED first,
/// so the new pixels can be seen.
pub(crate) fn add_pixels(
    edit: &mut Edit<'_>,
    state: &mut EditorState,
    make: impl FnOnce(&PixelTemplate) -> Vec<Pixel>,
) {
    let mut template = template(state, edit.value.next_pixel_number());
    let added = edit.change(state, |definition| {
        if definition.models.is_empty() {
            definition.models.push(Model::circle("led", FIRST_LED_M));
            template.model = 0;
        }
        definition.insert_pixels(make(&template))
    });
    state.selection = added.collect();
}

/// Most rows a grid of `columns` may have.
fn max_rows(columns: usize) -> usize {
    (MAX_LEDS / columns.max(1)).max(1)
}

/// The open form, if any. Adding or cancelling closes it.
pub(crate) fn show(ctx: &egui::Context, edit: &mut Edit<'_>, state: &mut EditorState) {
    let Some(kind) = state.generator else {
        return;
    };
    let mut chosen = None;
    // A panel in the canvas's corner rather than a dialog, so the preview of
    // what it adds stays in sight while the numbers change.
    let title = format!(
        "Add {}",
        kind.to_string().trim_end_matches('…').to_lowercase()
    );
    let corner = state.canvas_rect.left_top() + egui::vec2(8.0, 8.0);
    Window::new(title)
        .id(Id::new("fixture definition generator"))
        .fixed_pos(corner)
        .collapsible(false)
        .resizable(false)
        .show(ctx, |ui| {
            ui.set_width(340.0);
            form(ui, kind, &mut state.params);
            let pixels = state.params.pixels(kind, &template(state, 0)).len();
            ui.add_space(8.0);
            ui.horizontal(|ui| {
                if ui.button(format!("Add {pixels} pixels")).clicked() {
                    chosen = Some(true);
                }
                if ui.button("Cancel").clicked() {
                    chosen = Some(false);
                }
            });
        });
    // Enter adds, Escape cancels, unless a field is being typed into
    if chosen.is_none() && !state.typing && !ctx.text_edit_focused() {
        ctx.input_mut(|input| {
            if input.consume_key(egui::Modifiers::NONE, egui::Key::Enter) {
                chosen = Some(true);
            } else if input.consume_key(egui::Modifiers::NONE, egui::Key::Escape) {
                chosen = Some(false);
            }
        });
    }
    match chosen {
        Some(true) => {
            add(kind, edit, state);
            state.generator = None;
        }
        Some(false) => state.generator = None,
        None => {}
    }
}

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

    /// Regression: columns and rows were each held to the limit, so a grid
    /// could ask for 4096 × 4096 pixels, which froze the window while the
    /// form previewed them.
    #[test]
    fn a_grid_stays_within_the_led_limit() {
        let mut params = GeneratorParams::default();
        params.grid.columns = MAX_LEDS;
        params.grid.rows = MAX_LEDS;
        let template = PixelTemplate {
            prefix: DEFAULT_PIXEL_PREFIX.to_string(),
            first_number: 0,
            model: 0,
            order: ColorOrder::Rgb,
        };

        assert_eq!(
            params.pixels(GeneratorKind::Grid, &template).len(),
            MAX_LEDS
        );
    }
}