fix(leadin): add close button to Place Lead-in side panel

The docked side panel could only be closed with Escape, which was
unreliable: MainForm consumes Escape before ActionLeadIn's KeyDown
handler, so with a part selected ActionManager called the action's
empty CancelAction and nothing happened; with nothing selected the
panel closed but was stored as the previous action, so the next
Escape reopened it.

- EditNestForm side panel gets a header with the panel title and an
  always-visible close button that ends the action.
- ActionLeadIn.CancelAction now steps back (unlock contour, then
  deselect part), replacing the dead KeyDown handler.
- Actions can opt out of Escape-resume (ResumeOnEscape); the lead-in
  action does, so a closed panel stays closed.
This commit is contained in:
aj
2026-09-29 08:38:48 -04:00
parent 36de55f10f
commit 2b8a3ce7db
6 changed files with 149 additions and 29 deletions
@@ -0,0 +1,64 @@
using System.Runtime.ExceptionServices;
using System.Windows.Forms;
using OpenNest.Forms;
namespace OpenNest.WinForms.Tests.Forms;
public class LeadInSidePanelTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public void PanelClosesWithoutEscapeReopeningIt(bool useCloseButton) => RunSta(() =>
{
using var form = new EditNestForm(new Nest("lead-in panel"));
form.Show();
form.PlaceLeadIn_Click(null!, EventArgs.Empty);
Assert.True(form.IsSidePanelVisible);
Assert.Equal("Place Lead-in", form.PlateView.Status);
if (useCloseButton)
{
var close = Assert.IsType<Button>(
Assert.Single(form.Controls.Find("sidePanelCloseButton", true))
);
close.PerformClick();
}
else
{
form.PlateView.ProcessEscapeKey();
}
Assert.False(form.IsSidePanelVisible);
Assert.Equal("Select", form.PlateView.Status);
// Escape from Select normally resumes the previous action; it must not
// bring back a panel the user just closed.
form.PlateView.ProcessEscapeKey();
Assert.False(form.IsSidePanelVisible);
Assert.Equal("Select", form.PlateView.Status);
});
private static void RunSta(System.Action action)
{
Exception? failure = null;
var thread = new Thread(() =>
{
try
{
action();
}
catch (Exception ex)
{
failure = ex;
}
})
{ IsBackground = true };
thread.SetApartmentState(ApartmentState.STA);
thread.Start();
Assert.True(thread.Join(TimeSpan.FromSeconds(60)), "The STA test did not complete.");
if (failure != null)
ExceptionDispatchInfo.Capture(failure).Throw();
}
}
+5
View File
@@ -28,6 +28,11 @@ namespace OpenNest.Actions
public virtual bool SurvivesPlateChange => false;
/// <summary>
/// Whether Escape from Select may bring this action back after it ends.
/// </summary>
public virtual bool ResumeOnEscape => true;
public virtual void OnPlateChanged() { }
public virtual void ConnectEvents() { }
+15 -28
View File
@@ -55,7 +55,6 @@ namespace OpenNest.Actions
{
plateView.MouseMove += OnMouseMove;
plateView.MouseDown += OnMouseDown;
plateView.KeyDown += OnKeyDown;
plateView.Paint += OnPaint;
ShowSidePanel();
}
@@ -64,7 +63,6 @@ namespace OpenNest.Actions
{
plateView.MouseMove -= OnMouseMove;
plateView.MouseDown -= OnMouseDown;
plateView.KeyDown -= OnKeyDown;
plateView.Paint -= OnPaint;
HideSidePanel();
@@ -72,23 +70,25 @@ namespace OpenNest.Actions
contextMenu?.Dispose();
contextMenu = null;
if (selectedLayoutPart != null)
selectedLayoutPart.IsSelected = false;
selectedLayoutPart = null;
selectedPart = null;
profile = null;
contours = null;
hasSnap = false;
activeSnapType = SnapType.None;
hoveredContour = null;
plateView.Invalidate();
DeselectPart();
}
public override void CancelAction() { }
// Escape reaches here through ActionManager.ProcessEscapeKey while IsBusy:
// step back one level (unlock the contour, then release the part). Once
// nothing is selected, Escape ends the action and closes the side panel.
public override void CancelAction()
{
if (lockedContour != null)
UnlockContour();
else
DeselectPart();
}
public override bool IsBusy() => selectedPart != null;
// Escape closes the side panel; a second Escape must not reopen it.
public override bool ResumeOnEscape => false;
private void ShowSidePanel()
{
var form = plateView.FindForm() as EditNestForm;
@@ -119,7 +119,7 @@ namespace OpenNest.Actions
}
}
form.ShowSidePanel(cuttingPanel);
form.ShowSidePanel(cuttingPanel, "Place Lead-in");
}
private void HideSidePanel()
@@ -249,19 +249,6 @@ namespace OpenNest.Actions
}
}
private void OnKeyDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Escape)
{
if (lockedContour != null)
UnlockContour();
else if (selectedPart != null)
DeselectPart();
else
plateView.SetAction(typeof(ActionSelect));
}
}
private void OnPaint(object sender, PaintEventArgs e)
{
var g = e.Graphics;
+12
View File
@@ -28,6 +28,7 @@ namespace OpenNest.Controls
if (
type == typeof(Actions.ActionSelect)
&& !(currentAction is Actions.ActionSelect)
&& currentAction.ResumeOnEscape
)
previousAction = currentAction;
else
@@ -75,6 +76,17 @@ namespace OpenNest.Controls
view.SetAction(typeof(Actions.ActionSelect));
}
/// <summary>
/// Ends the current action at the user's explicit request (for example a
/// side panel's Close button). Unlike Escape, the ended action is not kept
/// for a later Escape to restore.
/// </summary>
public void EndAction()
{
SetAction(typeof(Actions.ActionSelect));
previousAction = null;
}
public void RestorePreviousAction()
{
var action = previousAction;
+2
View File
@@ -487,6 +487,8 @@ namespace OpenNest.Controls
public void ProcessEscapeKey() => actionManager.ProcessEscapeKey();
public void EndAction() => actionManager.EndAction();
protected override bool ProcessDialogKey(Keys keyData)
{
// Only handle TAB, RETURN, ESC, and ARROW KEYS here.
+51 -1
View File
@@ -53,6 +53,7 @@ namespace OpenNest.Forms
private SplitContainer viewSplitContainer;
private Panel sidePanel;
private Label sidePanelTitle;
/// <summary>
/// Used to distinguish between single/double click on drawing within drawinglistbox.
@@ -163,6 +164,40 @@ namespace OpenNest.Forms
BackColor = Color.White,
};
// The header sits outside the scrolling content so Close stays visible.
sidePanelTitle = new Label
{
Dock = DockStyle.Fill,
AutoEllipsis = true,
TextAlign = ContentAlignment.MiddleLeft,
Font = new Font("Segoe UI", 9f, FontStyle.Bold),
};
var closeButton = new Button
{
Name = "sidePanelCloseButton",
Text = "\u00D7",
AccessibleName = "Close panel",
Dock = DockStyle.Right,
Width = 30,
Font = new Font("Segoe UI", 12f),
FlatStyle = FlatStyle.Flat,
FlatAppearance = { BorderSize = 0 },
Cursor = Cursors.Hand,
};
closeButton.Click += (s, e) => CloseSidePanel();
new ToolTip(components).SetToolTip(closeButton, "Close (Esc)");
var sidePanelHeader = new Panel
{
Dock = DockStyle.Top,
Height = 30,
BackColor = Color.FromArgb(240, 240, 240),
Padding = new Padding(8, 0, 0, 0),
};
sidePanelHeader.Controls.Add(sidePanelTitle);
sidePanelHeader.Controls.Add(closeButton);
viewSplitContainer = new SplitContainer
{
Dock = DockStyle.Fill,
@@ -173,14 +208,18 @@ namespace OpenNest.Forms
viewSplitContainer.Panel1.Controls.Add(PlateView);
viewSplitContainer.Panel2.Controls.Add(sidePanel);
viewSplitContainer.Panel2.Controls.Add(sidePanelHeader);
viewSplitContainer.Panel2Collapsed = true;
}
public void ShowSidePanel(Control content, int width = 390)
public bool IsSidePanelVisible => !viewSplitContainer.Panel2Collapsed;
public void ShowSidePanel(Control content, string title, int width = 390)
{
sidePanel.Controls.Clear();
content.Dock = DockStyle.Fill;
sidePanel.Controls.Add(content);
sidePanelTitle.Text = title;
viewSplitContainer.SplitterDistance = viewSplitContainer.Width - width;
viewSplitContainer.Panel2Collapsed = false;
}
@@ -189,6 +228,17 @@ namespace OpenNest.Forms
{
viewSplitContainer.Panel2Collapsed = true;
sidePanel.Controls.Clear();
sidePanelTitle.Text = string.Empty;
}
private void CloseSidePanel()
{
// Side panels belong to the plate action that opened them; ending the
// action hides its panel. Hide directly as well so Close always closes.
PlateView.EndAction();
if (IsSidePanelVisible)
HideSidePanel();
PlateView.Focus();
}
private static Button CreateNavButton(System.Drawing.Image image)