feat(ui): add PhaseStepperControl for nesting progress phases

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-18 17:31:54 -04:00
parent a1810db96d
commit 97ab33c899

View File

@@ -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<NestPhase> 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);
}
}
}
}