gled-ui 2.32.1

gled's egui interface, shared by the desktop app and the browser
Documentation
//! Drawing LEDs onto the canvas: drag out a line or a circle, or click out a
//! path, set how many LEDs it carries, and add them. The LEDs are previewed
//! along the shape while it is drawn and while the count changes.

use crate::canvas::PREVIEW_COLOR;
use crate::canvas::rotate_handle::SNAP_DEG;
use crate::fixture_definition_editor::canvas_geometry::to_scene;
use crate::fixture_definition_editor::edit::Edit;
use crate::fixture_definition_editor::generators;
use crate::fixture_definition_editor::shape::Shape;
use crate::fixture_definition_editor::state::EditorState;
use egui::{
    Key, Modifiers, Painter, Pos2, Response, Shape as Drawing, Stroke, emath::TSTransform, vec2,
};
use egui_phosphor_icons::icons;
use gled_model::patching::definition::Pixel;
use strum::{EnumIter, IntoStaticStr};

/// How close to a path's first point a click closes it, in screen pixels.
const CLOSE_PX: f32 = 10.0;
/// Corners up to which a path shows a dot on each; a path from a file has
/// hundreds.
const MAX_CORNER_DOTS: usize = 64;

/// What a press on the canvas does.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, EnumIter, IntoStaticStr)]
pub(crate) enum Tool {
    /// Picks and moves pixels.
    #[default]
    Select,
    Line,
    Circle,
    Path,
}

impl Tool {
    /// Its icon and its name, for its button.
    pub(crate) fn label(self) -> (egui::RichText, &'static str) {
        let icon = match self {
            Self::Select => icons::CURSOR,
            Self::Line => icons::LINE_SEGMENT,
            Self::Circle => icons::CIRCLE,
            Self::Path => icons::PATH,
        };
        (icon.regular(), self.into())
    }

    /// The key that picks the tool.
    pub(crate) fn key(self) -> Key {
        match self {
            Self::Select => Key::V,
            Self::Line => Key::L,
            Self::Circle => Key::C,
            Self::Path => Key::P,
        }
    }

    pub(crate) fn hint(self) -> &'static str {
        match self {
            Self::Select => "",
            Self::Line => "Drag from the first LED to the last; shift keeps to 15° steps",
            Self::Circle => "Drag from the centre to where the first LED goes",
            Self::Path => {
                "Click the corners; click the first one again to close the loop, double-click or press Enter to end it"
            }
        }
    }
}

/// Rounds a position to whole millimetres, so drawn LEDs land on tidy values.
pub(crate) fn snap(metres: [f32; 2]) -> [f32; 2] {
    metres.map(|value| (value * 1000.0).round() / 1000.0)
}

/// The LEDs the drawn shape would add, for the canvas to preview.
pub(crate) fn preview(edit: &Edit<'_>, state: &EditorState) -> Vec<Pixel> {
    state
        .draw
        .pixels(&generators::template(state, edit.value.next_pixel_number()))
}

/// Where the pointer is, for drawing.
pub(crate) struct Pointer {
    /// The pointer now, in metres.
    pub(crate) at: Option<[f32; 2]>,
    /// Where the button went down, in metres.
    pub(crate) press: Option<[f32; 2]>,
    /// Where a click happened, in metres.
    pub(crate) clicked_at: Option<[f32; 2]>,
    /// Screen pixels per metre.
    pub(crate) pixels_per_metre: f32,
    pub(crate) modifiers: Modifiers,
}

/// What a press, drag or click does with a drawing tool.
pub(crate) fn interact(state: &mut EditorState, response: &Response, pointer: &Pointer) {
    match state.draw.tool {
        Tool::Select => {}
        Tool::Line => draw_line(state, response, pointer),
        Tool::Circle => draw_circle(state, response, pointer),
        Tool::Path => draw_path(state, response, pointer),
    }
}

fn draw_line(state: &mut EditorState, response: &Response, pointer: &Pointer) {
    if response.drag_started_by(egui::PointerButton::Primary)
        && let Some(press) = pointer.press
    {
        let from = snap(press);
        state.draw.shape = Some(Shape::Line { from, to: from });
    }
    // a slow frame may take in the last moves and the release at once
    if (response.dragged_by(egui::PointerButton::Primary)
        || response.drag_stopped_by(egui::PointerButton::Primary))
        && let (Some(at), Some(Shape::Line { from, to })) = (pointer.at, &mut state.draw.shape)
    {
        let mut end = at;
        if pointer.modifiers.shift {
            let delta = vec2(at[0] - from[0], at[1] - from[1]);
            let step = SNAP_DEG.to_radians();
            let angle = (delta.y.atan2(delta.x) / step).round() * step;
            let length = delta.length();
            end = [
                from[0] + length * angle.cos(),
                from[1] + length * angle.sin(),
            ];
        }
        *to = snap(end);
    }
}

fn draw_circle(state: &mut EditorState, response: &Response, pointer: &Pointer) {
    if response.drag_started_by(egui::PointerButton::Primary)
        && let Some(press) = pointer.press
    {
        state.draw.shape = Some(Shape::Circle {
            centre: snap(press),
            radius: 0.0,
            start_deg: 0.0,
        });
    }
    // a slow frame may take in the last moves and the release at once
    if (response.dragged_by(egui::PointerButton::Primary)
        || response.drag_stopped_by(egui::PointerButton::Primary))
        && let (
            Some(at),
            Some(Shape::Circle {
                centre,
                radius,
                start_deg,
            }),
        ) = (pointer.at, &mut state.draw.shape)
    {
        let delta = vec2(at[0] - centre[0], at[1] - centre[1]);
        *radius = (delta.length() * 1000.0).round() / 1000.0;
        // clockwise from twelve o'clock, y pointing down
        *start_deg = delta.x.atan2(-delta.y).to_degrees();
    }
}

fn draw_path(state: &mut EditorState, response: &Response, pointer: &Pointer) {
    let Some(at) = pointer.clicked_at.map(snap) else {
        return;
    };
    if response.double_clicked() {
        if let Some(Shape::Path {
            points, finished, ..
        }) = &mut state.draw.shape
        {
            // the double click's first click already put a corner there
            points.dedup();
            *finished = true;
        }
        return;
    }
    if !response.clicked_by(egui::PointerButton::Primary) {
        return;
    }
    match &mut state.draw.shape {
        Some(Shape::Path {
            points,
            closed,
            finished: finished @ false,
        }) => {
            let first = points[0];
            let distance_px =
                vec2(at[0] - first[0], at[1] - first[1]).length() * pointer.pixels_per_metre;
            if points.len() >= 3 && distance_px <= CLOSE_PX {
                *closed = true;
                *finished = true;
            } else {
                points.push(at);
            }
        }
        _ => {
            state.draw.shape = Some(Shape::Path {
                points: vec![at],
                closed: false,
                finished: false,
            });
        }
    }
}

/// Draws the shape, and for an unfinished path the segment to the pointer.
pub(crate) fn paint(
    painter: &Painter,
    to_screen: TSTransform,
    origin: Pos2,
    state: &EditorState,
    pointer_m: Option<[f32; 2]>,
) {
    let stroke = Stroke::new(1.5, PREVIEW_COLOR);
    let screen = |metres: [f32; 2]| to_screen * to_scene(origin, [metres[0], metres[1], 0.0]);
    match &state.draw.shape {
        Some(Shape::Line { from, to }) => {
            painter.line_segment([screen(*from), screen(*to)], stroke);
        }
        Some(Shape::Circle { centre, radius, .. }) => {
            let centre_px = screen(*centre);
            let radius_px =
                to_screen.scaling * radius * gled_model::patching::fixture::UNITS_PER_METRE;
            painter.circle_stroke(centre_px, radius_px, stroke);
            painter.circle_filled(centre_px, 2.5, PREVIEW_COLOR);
        }
        Some(Shape::Path {
            points,
            closed,
            finished,
        }) => {
            let mut corners = points.clone();
            if !finished && let Some(at) = pointer_m {
                corners.push(snap(at));
            }
            let mut line: Vec<Pos2> = state
                .draw
                .course(&corners, *closed)
                .into_iter()
                .map(screen)
                .collect();
            if *closed && let Some(first) = line.first().copied() {
                line.push(first);
            }
            painter.add(Drawing::line(line, stroke));
            if points.len() <= MAX_CORNER_DOTS {
                for point in points {
                    painter.circle_filled(screen(*point), 3.0, PREVIEW_COLOR);
                }
            }
        }
        None => {}
    }
}

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

    #[test]
    fn positions_snap_to_millimetres() {
        assert_eq!(snap([0.01234, -0.0006]), [0.012, -0.001]);
    }
}