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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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() { }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user