From 97ab33c8995db5a0ac61be40c0c29aca4444948c Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Wed, 18 Mar 2026 17:31:54 -0400 Subject: [PATCH] feat(ui): add PhaseStepperControl for nesting progress phases Co-Authored-By: Claude Sonnet 4.6 --- OpenNest/Controls/PhaseStepperControl.cs | 146 +++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 OpenNest/Controls/PhaseStepperControl.cs diff --git a/OpenNest/Controls/PhaseStepperControl.cs b/OpenNest/Controls/PhaseStepperControl.cs new file mode 100644 index 0000000..c214306 --- /dev/null +++ b/OpenNest/Controls/PhaseStepperControl.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.Windows.Forms; + +namespace OpenNest.Controls +{ + public class PhaseStepperControl : UserControl + { + private static readonly Color AccentColor = Color.FromArgb(0, 120, 212); + private static readonly Color GlowColor = Color.FromArgb(60, 0, 120, 212); + private static readonly Color PendingBorder = Color.FromArgb(192, 192, 192); + private static readonly Color LineColor = Color.FromArgb(208, 208, 208); + private static readonly Color PendingTextColor = Color.FromArgb(153, 153, 153); + private static readonly Color ActiveTextColor = Color.FromArgb(51, 51, 51); + + private static readonly Font LabelFont = new Font("Segoe UI", 8f, FontStyle.Regular); + private static readonly Font BoldLabelFont = new Font("Segoe UI", 8f, FontStyle.Bold); + + private static readonly NestPhase[] Phases = (NestPhase[])Enum.GetValues(typeof(NestPhase)); + + private readonly HashSet visitedPhases = new(); + private NestPhase? activePhase; + private bool isComplete; + + public PhaseStepperControl() + { + DoubleBuffered = true; + SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); + Height = 60; + } + + public NestPhase? ActivePhase + { + get => activePhase; + set + { + activePhase = value; + if (value.HasValue) + visitedPhases.Add(value.Value); + Invalidate(); + } + } + + public bool IsComplete + { + get => isComplete; + set + { + isComplete = value; + if (value) + { + foreach (var phase in Phases) + visitedPhases.Add(phase); + activePhase = null; + } + Invalidate(); + } + } + + private static string GetDisplayName(NestPhase phase) + { + switch (phase) + { + case NestPhase.RectBestFit: return "BestFit"; + case NestPhase.Nfp: return "NFP"; + default: return phase.ToString(); + } + } + + protected override void OnPaint(PaintEventArgs e) + { + base.OnPaint(e); + var g = e.Graphics; + g.SmoothingMode = SmoothingMode.AntiAlias; + g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; + + var count = Phases.Length; + if (count == 0) return; + + var padding = 30; + var usableWidth = Width - padding * 2; + var spacing = usableWidth / (count - 1); + var circleY = 18; + var normalRadius = 9; + var activeRadius = 11; + + using var linePen = new Pen(LineColor, 2f); + using var accentBrush = new SolidBrush(AccentColor); + using var glowBrush = new SolidBrush(GlowColor); + using var pendingPen = new Pen(PendingBorder, 2f); + using var activeTextBrush = new SolidBrush(ActiveTextColor); + using var pendingTextBrush = new SolidBrush(PendingTextColor); + + // Draw connecting lines + for (var i = 0; i < count - 1; i++) + { + var x1 = padding + i * spacing; + var x2 = padding + (i + 1) * spacing; + g.DrawLine(linePen, x1, circleY, x2, circleY); + } + + // Draw circles and labels + for (var i = 0; i < count; i++) + { + var phase = Phases[i]; + var cx = padding + i * spacing; + var isActive = activePhase == phase && !isComplete; + var isVisited = visitedPhases.Contains(phase) || isComplete; + + if (isActive) + { + // Glow + g.FillEllipse(glowBrush, + cx - activeRadius - 3, circleY - activeRadius - 3, + (activeRadius + 3) * 2, (activeRadius + 3) * 2); + // Filled circle + g.FillEllipse(accentBrush, + cx - activeRadius, circleY - activeRadius, + activeRadius * 2, activeRadius * 2); + } + else if (isVisited) + { + g.FillEllipse(accentBrush, + cx - normalRadius, circleY - normalRadius, + normalRadius * 2, normalRadius * 2); + } + else + { + g.DrawEllipse(pendingPen, + cx - normalRadius, circleY - normalRadius, + normalRadius * 2, normalRadius * 2); + } + + // Label + var label = GetDisplayName(phase); + var font = isVisited || isActive ? BoldLabelFont : LabelFont; + var brush = isVisited || isActive ? activeTextBrush : pendingTextBrush; + var labelSize = g.MeasureString(label, font); + g.DrawString(label, font, brush, + cx - labelSize.Width / 2, circleY + activeRadius + 5); + } + } + } +}