mirror of
https://github.com/ajisaacs/OpenNest.git
synced 2026-10-06 19:42:10 -04:00
feat(ui): open Database-mode nests from a larger saved-nest browser
File > Open in Database mode now shows a resizable browser instead of the small list dialog. The upper Nests grid keeps the server-side search, sort and paging (row numbers count through the filtered list; the title shows the server and the range). Below it, Details tabs list the highlighted nest's plates and drawings, read from its archive after the highlight rests for 250 ms; moving back to a nest already shown reuses its result, and a missing archive is reported in the details line. Enter opens the highlighted nest (in the Find box it runs the search at once instead) and Esc closes. Double-click opens a nest, and a context menu offers Open, Delete (after confirmation) and Refresh. It only browses saved nests; new nests are still created from the main window. Windows tests drive the real form: page load, details for two nests, Enter opening the highlighted one, the missing-archive message and Esc.
This commit is contained in:
1 parent
0446127e11
commit
9920aba6eb
4 files changed
+628
-145
No files matched your search
@@ -0,0 +1,169 @@
|
||||
using System.Reflection;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.CNC;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.Forms;
|
||||
using OpenNest.Geometry;
|
||||
using OpenNest.IO;
|
||||
|
||||
namespace OpenNest.WinForms.Tests.Forms;
|
||||
|
||||
public class SavedNestsFormTests
|
||||
{
|
||||
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(60);
|
||||
|
||||
[Fact]
|
||||
public void HighlightedNestShowsItsPlatesAndDrawings_AndEnterOpensIt()
|
||||
{
|
||||
StaTestThread.Run(
|
||||
() =>
|
||||
{
|
||||
var first = Record("First job");
|
||||
var second = Record("Second job");
|
||||
var repository = new FakeRepository(first, second);
|
||||
repository.Files[first.Id] = NestFile(plateQuantity: 2, partsOnPlate: 3);
|
||||
repository.Files[second.Id] = NestFile(plateQuantity: 1, partsOnPlate: 1);
|
||||
|
||||
using var form = new SavedNestsForm(repository, "http://nest-server:5000");
|
||||
form.Show();
|
||||
var nests = Field<DataGridView>(form, "nestGrid");
|
||||
var plates = Field<DataGridView>(form, "platesGrid");
|
||||
var drawings = Field<DataGridView>(form, "drawingsGrid");
|
||||
|
||||
PumpUntil(() => nests.Rows.Count == 2, "the nest page");
|
||||
Assert.Equal("First job", nests.Rows[0].Cells[0].Value);
|
||||
Assert.Equal("1", nests.Rows[0].HeaderCell.Value);
|
||||
Assert.Contains("http://nest-server:5000", form.Text);
|
||||
Assert.Contains("Showing 1-2 of 2 nests", form.Text);
|
||||
|
||||
PumpUntil(() => plates.Rows.Count == 1, "the first nest's plates");
|
||||
Assert.Equal(2, plates.Rows[0].Cells["Duplicates"].Value);
|
||||
Assert.Equal(3, plates.Rows[0].Cells["Parts"].Value);
|
||||
Assert.Equal("Plate size (in)", plates.Columns["PlateSize"].HeaderText);
|
||||
Assert.Equal(6, drawings.Rows[0].Cells["Nested"].Value);
|
||||
|
||||
nests.CurrentCell = nests.Rows[1].Cells[0];
|
||||
PumpUntil(
|
||||
() => plates.Rows.Count == 1 && Equals(plates.Rows[0].Cells["Duplicates"].Value, 1),
|
||||
"the second nest's plates");
|
||||
Assert.Equal(1, drawings.Rows[0].Cells["Nested"].Value);
|
||||
|
||||
Assert.True(ProcessKey(form, Keys.Enter));
|
||||
Assert.Equal(DialogResult.OK, form.DialogResult);
|
||||
Assert.Equal(second.Id, form.SelectedId);
|
||||
},
|
||||
TestTimeout,
|
||||
"The saved-nest dialog test did not complete.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MissingArchive_ShowsTheErrorInTheDetailsLine_AndEscapeCancels()
|
||||
{
|
||||
StaTestThread.Run(
|
||||
() =>
|
||||
{
|
||||
var gone = Record("Deleted elsewhere");
|
||||
using var form = new SavedNestsForm(new FakeRepository(gone));
|
||||
form.Show();
|
||||
var status = Field<Label>(form, "detailsStatus");
|
||||
|
||||
PumpUntil(() => status.Text.Contains("no longer exists"), "the missing-nest message");
|
||||
Assert.Equal(0, Field<DataGridView>(form, "platesGrid").Rows.Count);
|
||||
|
||||
Assert.True(ProcessKey(form, Keys.Escape));
|
||||
Assert.Equal(DialogResult.Cancel, form.DialogResult);
|
||||
Assert.Equal(Guid.Empty, form.SelectedId);
|
||||
},
|
||||
TestTimeout,
|
||||
"The saved-nest dialog test did not complete.");
|
||||
}
|
||||
|
||||
private static NestRecord Record(string name) =>
|
||||
new() { Id = Guid.NewGuid(), Name = name, SavedAt = DateTime.Now };
|
||||
|
||||
private static byte[] NestFile(int plateQuantity, int partsOnPlate)
|
||||
{
|
||||
var nest = new Nest("Job") { Units = Units.Inches };
|
||||
var program = new OpenNest.CNC.Program();
|
||||
program.Codes.Add(new RapidMove(new Vector(0, 0)));
|
||||
program.Codes.Add(new LinearMove(new Vector(10, 0)));
|
||||
program.Codes.Add(new LinearMove(new Vector(10, 10)));
|
||||
program.Codes.Add(new LinearMove(new Vector(0, 10)));
|
||||
program.Codes.Add(new LinearMove(new Vector(0, 0)));
|
||||
var drawing = new Drawing("Square", program);
|
||||
drawing.Quantity.Required = 10;
|
||||
nest.Drawings.Add(drawing);
|
||||
|
||||
var plate = nest.CreatePlate();
|
||||
plate.Size = new OpenNest.Geometry.Size(48, 96);
|
||||
plate.Quantity = plateQuantity;
|
||||
for (var i = 0; i < partsOnPlate; i++)
|
||||
plate.Parts.Add(new Part(drawing, new Vector(i * 12, 0)));
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
new NestWriter(nest).Write(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
private static void PumpUntil(Func<bool> condition, string what)
|
||||
{
|
||||
var until = DateTime.UtcNow.AddSeconds(15);
|
||||
while (!condition() && DateTime.UtcNow < until)
|
||||
{
|
||||
Application.DoEvents();
|
||||
Thread.Sleep(10);
|
||||
}
|
||||
|
||||
Assert.True(condition(), $"Timed out waiting for {what}.");
|
||||
}
|
||||
|
||||
private static bool ProcessKey(Form form, Keys key)
|
||||
{
|
||||
var method = form.GetType().GetMethod("ProcessCmdKey", BindingFlags.Instance | BindingFlags.NonPublic)!;
|
||||
return (bool)method.Invoke(form, new object[] { new Message(), key })!;
|
||||
}
|
||||
|
||||
private static T Field<T>(object owner, string name) =>
|
||||
(T)owner.GetType().GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(owner)!;
|
||||
|
||||
/// <summary>Serves one page of the given records and archives by id.</summary>
|
||||
private sealed class FakeRepository : INestRepository
|
||||
{
|
||||
private readonly NestRecord[] _records;
|
||||
|
||||
public FakeRepository(params NestRecord[] records) => _records = records;
|
||||
|
||||
public Dictionary<Guid, byte[]> Files { get; } = new();
|
||||
|
||||
public Task<NestPage> QueryAsync(NestQuery query, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(new NestPage
|
||||
{
|
||||
Items = _records,
|
||||
Total = _records.Length,
|
||||
Offset = query.Offset,
|
||||
Limit = query.Limit,
|
||||
});
|
||||
|
||||
public Task<byte[]?> GetFileAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Files.TryGetValue(id, out var file) ? file : null);
|
||||
|
||||
public Task<IReadOnlyList<NestRecord>> ListAsync(CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<NestRecord?> GetMetadataAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<NestRecord> UploadAsync(byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<NestRecord> UpdateFileAsync(
|
||||
Guid id, byte[] nestFile, NestRecord record, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task<NestRecord> UpdateMetadataAsync(Guid id, NestRecord record, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
}
|
||||
@@ -602,7 +602,7 @@ namespace OpenNest.Forms
|
||||
try
|
||||
{
|
||||
var repository = NestStorage.CreateRepository();
|
||||
using var dlg = new SavedNestsForm(repository);
|
||||
using var dlg = new SavedNestsForm(repository, NestStorage.Settings.ServerUrl);
|
||||
if (dlg.ShowDialog(this) != DialogResult.OK)
|
||||
return;
|
||||
|
||||
|
||||
+441
-132
@@ -1,56 +1,176 @@
|
||||
using System;
|
||||
using System.Drawing;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using OpenNest.Data;
|
||||
using OpenNest.IO;
|
||||
using Timer = System.Windows.Forms.Timer;
|
||||
|
||||
namespace OpenNest.Forms;
|
||||
|
||||
/// <summary>
|
||||
/// File > Open (Database mode): browses nests on the shared nest server one bounded
|
||||
/// page at a time. Filtering, sorting and paging run on the server through
|
||||
/// <see cref="NestBrowseSession"/>; this form only renders its state.
|
||||
/// File > Open (Database mode). The upper list browses saved nests on the shared nest
|
||||
/// server one bounded page at a time; filtering, sorting and paging run on the server
|
||||
/// through <see cref="NestBrowseSession"/>. The lower tabs show the plates and drawings of
|
||||
/// the highlighted nest, read from its archive through <see cref="NestDetailsSession"/>.
|
||||
/// This form only renders their state.
|
||||
/// </summary>
|
||||
public sealed class SavedNestsForm : Form
|
||||
{
|
||||
private const string IdColumn = "Id";
|
||||
private const string PlateSizeColumn = "PlateSize";
|
||||
private const string AreaColumn = "Area";
|
||||
private const string LoadingText = "Loading...";
|
||||
private const string LoadingDetailsText = "Loading details...";
|
||||
private const string WindowTitle = "Open Nest — Database";
|
||||
|
||||
private static readonly Font SectionFont = new(SystemFonts.MessageBoxFont.FontFamily, 14f);
|
||||
|
||||
private readonly INestRepository repository;
|
||||
private readonly string serverUrl;
|
||||
private readonly NestBrowseSession session;
|
||||
private readonly DataGridView grid;
|
||||
private readonly NestDetailsSession details;
|
||||
private readonly SplitContainer split;
|
||||
private readonly DataGridView nestGrid;
|
||||
private readonly DataGridView platesGrid;
|
||||
private readonly DataGridView drawingsGrid;
|
||||
private readonly TabControl detailsTabs;
|
||||
private readonly Label detailsStatus;
|
||||
private readonly TextBox searchBox;
|
||||
private readonly Timer searchTimer;
|
||||
private readonly Button openButton;
|
||||
private readonly Button deleteButton;
|
||||
private readonly Timer detailsTimer;
|
||||
private readonly Button previousButton;
|
||||
private readonly Button nextButton;
|
||||
private readonly ToolStripStatusLabel statusLabel;
|
||||
private readonly Label summaryLabel;
|
||||
private readonly ToolStripMenuItem openMenuItem;
|
||||
private readonly ToolStripMenuItem deleteMenuItem;
|
||||
private bool deleting;
|
||||
|
||||
/// <summary>Set to the chosen record's id when the dialog closes with OK.</summary>
|
||||
public Guid SelectedId { get; private set; }
|
||||
|
||||
public SavedNestsForm(INestRepository repository)
|
||||
public SavedNestsForm(INestRepository repository, string serverUrl = null)
|
||||
{
|
||||
this.repository = repository;
|
||||
this.serverUrl = serverUrl ?? "";
|
||||
session = new NestBrowseSession(repository);
|
||||
details = new NestDetailsSession(LoadDetailsAsync);
|
||||
|
||||
Text = "Open Nest — Database";
|
||||
Text = WindowTitle;
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
ClientSize = new Size(880, 480);
|
||||
ClientSize = new Size(1280, 800);
|
||||
MinimumSize = new Size(800, 560);
|
||||
MinimizeBox = false;
|
||||
MaximizeBox = false;
|
||||
ShowInTaskbar = false;
|
||||
|
||||
var topPanel = new FlowLayoutPanel
|
||||
// Nests: one server page, sorted by the server when a header is clicked.
|
||||
nestGrid = CreateGrid();
|
||||
nestGrid.RowHeadersWidth = 64;
|
||||
AddNestColumn("Nest name", NestSortField.Name, 170);
|
||||
AddNestColumn("Status", NestSortField.Status, 100);
|
||||
AddNestColumn("Modified", NestSortField.DateModified, 140);
|
||||
AddNestColumn("Plates", NestSortField.PlateCount, 60, alignRight: true);
|
||||
AddNestColumn("Parts", NestSortField.PartCount, 60, alignRight: true);
|
||||
AddNestColumn("Comment", NestSortField.Comments, 260);
|
||||
AddNestColumn("Customer", NestSortField.Customer, 160);
|
||||
AddNestColumn("Made by", NestSortField.MadeBy, 90);
|
||||
AddNestColumn("Material", NestSortField.Material, 150);
|
||||
AddNestColumn("Thickness", NestSortField.Thickness, 80, alignRight: true);
|
||||
AddNestColumn("Created", NestSortField.DateCreated, 140);
|
||||
AddNestColumn("Saved", NestSortField.SavedAt, 140);
|
||||
AddNestColumn("File size", NestSortField.FileSize, 80, alignRight: true);
|
||||
nestGrid.Columns.Add(new DataGridViewTextBoxColumn
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Height = 34,
|
||||
Padding = new Padding(3),
|
||||
WrapContents = false,
|
||||
Name = IdColumn,
|
||||
ValueType = typeof(Guid),
|
||||
Visible = false,
|
||||
SortMode = DataGridViewColumnSortMode.NotSortable,
|
||||
});
|
||||
// Sorting a page locally would misrepresent the archive; the server sorts all matches.
|
||||
nestGrid.ColumnHeaderMouseClick += async (_, e) =>
|
||||
{
|
||||
if (e.Button == MouseButtons.Left && nestGrid.Columns[e.ColumnIndex].Tag is NestSortField field)
|
||||
await RunAsync(() => session.SortByAsync(field));
|
||||
};
|
||||
nestGrid.CellDoubleClick += (_, e) =>
|
||||
{
|
||||
if (e.RowIndex >= 0)
|
||||
OpenSelected();
|
||||
};
|
||||
nestGrid.CellMouseDown += (_, e) =>
|
||||
{
|
||||
// Right-click highlights the row under the pointer before its menu opens.
|
||||
if (e.Button == MouseButtons.Right && e.RowIndex >= 0)
|
||||
nestGrid.CurrentCell = nestGrid.Rows[e.RowIndex].Cells[0];
|
||||
};
|
||||
nestGrid.SelectionChanged += (_, _) =>
|
||||
{
|
||||
UpdateCommands();
|
||||
ScheduleDetails();
|
||||
};
|
||||
|
||||
var menu = new ContextMenuStrip();
|
||||
openMenuItem = new ToolStripMenuItem("Open", null, (_, _) => OpenSelected()) { ShortcutKeyDisplayString = "Enter" };
|
||||
deleteMenuItem = new ToolStripMenuItem("Delete...", null, async (_, _) => await DeleteSelectedAsync());
|
||||
var refreshMenuItem = new ToolStripMenuItem("Refresh", null, async (_, _) => await RunAsync(session.RefreshAsync));
|
||||
menu.Items.AddRange(new ToolStripItem[] { openMenuItem, deleteMenuItem, new ToolStripSeparator(), refreshMenuItem });
|
||||
nestGrid.ContextMenuStrip = menu;
|
||||
|
||||
// Details of the highlighted nest.
|
||||
platesGrid = CreateGrid();
|
||||
platesGrid.RowHeadersVisible = false;
|
||||
AddColumn(platesGrid, "Number", "#", 50, typeof(int), alignRight: true);
|
||||
AddColumn(platesGrid, "Duplicates", "Duplicates", 90, typeof(int), alignRight: true);
|
||||
AddColumn(platesGrid, PlateSizeColumn, "Plate size", 140, typeof(string));
|
||||
AddColumn(platesGrid, "Parts", "Parts", 70, typeof(int), alignRight: true);
|
||||
AddColumn(platesGrid, "Drawings", "Drawings", 80, typeof(int), alignRight: true);
|
||||
AddColumn(platesGrid, "Utilization", "Utilization", 90, typeof(double), alignRight: true, format: "P1");
|
||||
|
||||
drawingsGrid = CreateGrid();
|
||||
drawingsGrid.RowHeadersVisible = false;
|
||||
AddColumn(drawingsGrid, "Drawing", "Drawing", 220, typeof(string));
|
||||
AddColumn(drawingsGrid, "Customer", "Customer", 160, typeof(string));
|
||||
AddColumn(drawingsGrid, "Required", "Required", 80, typeof(int), alignRight: true);
|
||||
AddColumn(drawingsGrid, "Nested", "Nested", 80, typeof(int), alignRight: true);
|
||||
AddColumn(drawingsGrid, "Remaining", "Remaining", 90, typeof(int), alignRight: true);
|
||||
AddColumn(drawingsGrid, AreaColumn, "Area", 100, typeof(double), alignRight: true, format: "0.###");
|
||||
|
||||
detailsTabs = new TabControl { Dock = DockStyle.Fill };
|
||||
detailsTabs.TabPages.Add(CreateTab("Plates", platesGrid));
|
||||
detailsTabs.TabPages.Add(CreateTab("Drawings", drawingsGrid));
|
||||
|
||||
detailsStatus = new Label
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
ForeColor = SystemColors.GrayText,
|
||||
AutoEllipsis = true,
|
||||
};
|
||||
var detailsHeader = new Panel { Dock = DockStyle.Top, Height = 32 };
|
||||
detailsHeader.Controls.Add(detailsStatus);
|
||||
detailsHeader.Controls.Add(SectionLabel("Details", DockStyle.Left));
|
||||
|
||||
split = new SplitContainer
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
Orientation = Orientation.Horizontal,
|
||||
SplitterWidth = 6,
|
||||
};
|
||||
split.Panel1.Controls.Add(nestGrid);
|
||||
split.Panel1.Controls.Add(SectionLabel("Nests", DockStyle.Top));
|
||||
split.Panel2.Controls.Add(detailsTabs);
|
||||
split.Panel2.Controls.Add(detailsHeader);
|
||||
|
||||
// Find bar and paging.
|
||||
searchBox = new TextBox
|
||||
{
|
||||
Width = 320,
|
||||
MaxLength = NestQuery.MaxSearchLength,
|
||||
PlaceholderText = "Name, customer, material, made by, comment or status",
|
||||
};
|
||||
var searchLabel = new Label { Text = "Filter:", AutoSize = true, Margin = new Padding(3, 7, 0, 0) };
|
||||
searchBox = new TextBox { Width = 300, MaxLength = NestQuery.MaxSearchLength };
|
||||
searchTimer = new Timer { Interval = 300 };
|
||||
searchTimer.Tick += async (_, _) =>
|
||||
{
|
||||
@@ -62,116 +182,172 @@ public sealed class SavedNestsForm : Form
|
||||
searchTimer.Stop();
|
||||
searchTimer.Start();
|
||||
};
|
||||
detailsTimer = new Timer { Interval = 250 };
|
||||
detailsTimer.Tick += async (_, _) =>
|
||||
{
|
||||
detailsTimer.Stop();
|
||||
if (SelectedNestId() is Guid id)
|
||||
await ShowDetailsAsync(id);
|
||||
};
|
||||
|
||||
var refreshButton = new Button { Text = "Refresh", AutoSize = true };
|
||||
refreshButton.Click += async (_, _) => await RunAsync(session.RefreshAsync);
|
||||
previousButton = new Button { Text = "< Previous", AutoSize = true, Enabled = false };
|
||||
previousButton = new Button { Text = "\u25C0", Width = 32, Enabled = false };
|
||||
previousButton.Click += async (_, _) => await RunAsync(session.PreviousPageAsync);
|
||||
nextButton = new Button { Text = "Next >", AutoSize = true, Enabled = false };
|
||||
nextButton = new Button { Text = "\u25B6", Width = 32, Enabled = false };
|
||||
nextButton.Click += async (_, _) => await RunAsync(session.NextPageAsync);
|
||||
topPanel.Controls.Add(searchLabel);
|
||||
topPanel.Controls.Add(searchBox);
|
||||
topPanel.Controls.Add(refreshButton);
|
||||
topPanel.Controls.Add(previousButton);
|
||||
topPanel.Controls.Add(nextButton);
|
||||
summaryLabel = new Label { AutoSize = true, Margin = new Padding(12, 8, 3, 0) };
|
||||
|
||||
grid = new DataGridView
|
||||
var findBar = new FlowLayoutPanel { AutoSize = true, Dock = DockStyle.Fill, WrapContents = false };
|
||||
findBar.Controls.Add(new Label { Text = "Find:", AutoSize = true, Margin = new Padding(3, 8, 0, 0) });
|
||||
findBar.Controls.Add(searchBox);
|
||||
findBar.Controls.Add(refreshButton);
|
||||
findBar.Controls.Add(previousButton);
|
||||
findBar.Controls.Add(nextButton);
|
||||
findBar.Controls.Add(summaryLabel);
|
||||
|
||||
var bottom = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
AutoSize = true,
|
||||
ColumnCount = 1,
|
||||
Padding = new Padding(3, 3, 3, 6),
|
||||
};
|
||||
bottom.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
bottom.Controls.Add(findBar);
|
||||
|
||||
Controls.Add(split);
|
||||
Controls.Add(bottom);
|
||||
|
||||
Load += (_, _) =>
|
||||
{
|
||||
// Minimum sizes are applied once the container has its real height; setting them
|
||||
// on the default-sized container throws.
|
||||
split.Panel1MinSize = 120;
|
||||
split.Panel2MinSize = 120;
|
||||
split.SplitterDistance = System.Math.Max(split.Panel1MinSize, split.Height * 3 / 5);
|
||||
};
|
||||
Shown += async (_, _) =>
|
||||
{
|
||||
nestGrid.Focus();
|
||||
await RunAsync(session.RefreshAsync);
|
||||
};
|
||||
UpdateCommands();
|
||||
}
|
||||
|
||||
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
|
||||
{
|
||||
switch (keyData)
|
||||
{
|
||||
case Keys.Enter when searchBox.Focused:
|
||||
// Run the typed search now instead of opening a row from the previous results.
|
||||
searchTimer.Stop();
|
||||
_ = RunAsync(() => session.SetSearchAsync(searchBox.Text));
|
||||
nestGrid.Focus();
|
||||
return true;
|
||||
case Keys.Enter:
|
||||
OpenSelected();
|
||||
return true;
|
||||
case Keys.Escape:
|
||||
DialogResult = DialogResult.Cancel;
|
||||
return true;
|
||||
}
|
||||
|
||||
return base.ProcessCmdKey(ref msg, keyData);
|
||||
}
|
||||
|
||||
private static DataGridView CreateGrid()
|
||||
{
|
||||
var grid = new DataGridView
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ReadOnly = true,
|
||||
AllowUserToAddRows = false,
|
||||
AllowUserToDeleteRows = false,
|
||||
AllowUserToResizeRows = false,
|
||||
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
|
||||
MultiSelect = false,
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.DisplayedCells,
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.None,
|
||||
BackgroundColor = Color.White,
|
||||
BorderStyle = BorderStyle.FixedSingle,
|
||||
GridColor = Color.Silver,
|
||||
EnableHeadersVisualStyles = false,
|
||||
ColumnHeadersHeightSizeMode = DataGridViewColumnHeadersHeightSizeMode.AutoSize,
|
||||
RowHeadersWidthSizeMode = DataGridViewRowHeadersWidthSizeMode.DisableResizing,
|
||||
StandardTab = true,
|
||||
};
|
||||
AddColumn("Name", NestSortField.Name, typeof(string));
|
||||
AddColumn("Customer", NestSortField.Customer, typeof(string));
|
||||
AddColumn("Status", NestSortField.Status, typeof(string));
|
||||
AddColumn("Material", NestSortField.Material, typeof(string));
|
||||
AddColumn("Date Created", NestSortField.DateCreated, typeof(DateTime));
|
||||
AddColumn("Date Modified", NestSortField.DateModified, typeof(DateTime));
|
||||
AddColumn("Thickness", NestSortField.Thickness, typeof(double));
|
||||
AddColumn("Plates", NestSortField.PlateCount, typeof(int));
|
||||
AddColumn("Parts", NestSortField.PartCount, typeof(int));
|
||||
AddColumn("Made By", NestSortField.MadeBy, typeof(string));
|
||||
AddColumn("Comments", NestSortField.Comments, typeof(string));
|
||||
AddColumn("File Size", NestSortField.FileSize, typeof(long));
|
||||
AddColumn("Saved", NestSortField.SavedAt, typeof(DateTime));
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = IdColumn,
|
||||
ValueType = typeof(Guid),
|
||||
Visible = false,
|
||||
SortMode = DataGridViewColumnSortMode.NotSortable,
|
||||
});
|
||||
// Sorting a page locally would misrepresent the archive; the server sorts all matches.
|
||||
grid.ColumnHeaderMouseClick += async (_, e) =>
|
||||
{
|
||||
if (e.Button == MouseButtons.Left && grid.Columns[e.ColumnIndex].Tag is NestSortField field)
|
||||
await RunAsync(() => session.SortByAsync(field));
|
||||
};
|
||||
grid.CellDoubleClick += (_, e) =>
|
||||
{
|
||||
if (e.RowIndex >= 0)
|
||||
Open_Click(this, EventArgs.Empty);
|
||||
};
|
||||
|
||||
var buttons = new FlowLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
Height = 44,
|
||||
FlowDirection = FlowDirection.RightToLeft,
|
||||
Padding = new Padding(6),
|
||||
};
|
||||
var cancel = new Button { Text = "Cancel", AutoSize = true, DialogResult = DialogResult.Cancel };
|
||||
deleteButton = new Button { Text = "Delete", AutoSize = true, Enabled = false };
|
||||
deleteButton.Click += Delete_Click;
|
||||
openButton = new Button { Text = "Open", AutoSize = true, Enabled = false };
|
||||
openButton.Click += Open_Click;
|
||||
buttons.Controls.Add(cancel);
|
||||
buttons.Controls.Add(openButton);
|
||||
buttons.Controls.Add(deleteButton);
|
||||
|
||||
var statusStrip = new StatusStrip { SizingGrip = false };
|
||||
statusLabel = new ToolStripStatusLabel { Spring = true, TextAlign = ContentAlignment.MiddleLeft };
|
||||
statusStrip.Items.Add(statusLabel);
|
||||
|
||||
grid.SelectionChanged += (_, _) =>
|
||||
{
|
||||
var hasSelection = grid.SelectedRows.Count > 0;
|
||||
openButton.Enabled = hasSelection;
|
||||
deleteButton.Enabled = hasSelection;
|
||||
};
|
||||
|
||||
Controls.Add(grid);
|
||||
Controls.Add(topPanel);
|
||||
Controls.Add(buttons);
|
||||
Controls.Add(statusStrip);
|
||||
CancelButton = cancel;
|
||||
|
||||
Shown += async (_, _) => await RunAsync(session.RefreshAsync);
|
||||
grid.RowTemplate.Height = 22;
|
||||
grid.DefaultCellStyle.SelectionBackColor = Color.Black;
|
||||
grid.DefaultCellStyle.SelectionForeColor = Color.White;
|
||||
grid.ColumnHeadersDefaultCellStyle.BackColor = SystemColors.Control;
|
||||
grid.ColumnHeadersDefaultCellStyle.SelectionBackColor = SystemColors.Control;
|
||||
grid.ColumnHeadersDefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleCenter;
|
||||
grid.RowHeadersDefaultCellStyle.BackColor = SystemColors.Control;
|
||||
grid.RowHeadersDefaultCellStyle.SelectionBackColor = Color.Black;
|
||||
grid.RowHeadersDefaultCellStyle.SelectionForeColor = Color.White;
|
||||
grid.RowHeadersDefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight;
|
||||
return grid;
|
||||
}
|
||||
|
||||
private void AddColumn(string name, NestSortField sortField, Type valueType)
|
||||
private static DataGridViewTextBoxColumn AddColumn(
|
||||
DataGridView grid,
|
||||
string name,
|
||||
string header,
|
||||
int width,
|
||||
Type valueType,
|
||||
bool alignRight = false,
|
||||
string format = null)
|
||||
{
|
||||
grid.Columns.Add(new DataGridViewTextBoxColumn
|
||||
var column = new DataGridViewTextBoxColumn
|
||||
{
|
||||
Name = name,
|
||||
HeaderText = name,
|
||||
HeaderText = header,
|
||||
Width = width,
|
||||
ValueType = valueType,
|
||||
SortMode = DataGridViewColumnSortMode.Programmatic,
|
||||
Tag = sortField,
|
||||
});
|
||||
};
|
||||
if (alignRight)
|
||||
column.DefaultCellStyle.Alignment = DataGridViewContentAlignment.MiddleRight;
|
||||
if (format != null)
|
||||
column.DefaultCellStyle.Format = format;
|
||||
grid.Columns.Add(column);
|
||||
return column;
|
||||
}
|
||||
|
||||
private void AddNestColumn(string header, NestSortField sortField, int width, bool alignRight = false)
|
||||
{
|
||||
// Cells hold display text: the server has already sorted the page.
|
||||
var column = AddColumn(nestGrid, header, header, width, typeof(string), alignRight);
|
||||
column.SortMode = DataGridViewColumnSortMode.Programmatic;
|
||||
column.Tag = sortField;
|
||||
}
|
||||
|
||||
private static TabPage CreateTab(string text, Control content)
|
||||
{
|
||||
var page = new TabPage(text) { Padding = new Padding(0) };
|
||||
page.Controls.Add(content);
|
||||
return page;
|
||||
}
|
||||
|
||||
private static Label SectionLabel(string text, DockStyle dock) =>
|
||||
new()
|
||||
{
|
||||
Text = text,
|
||||
Font = SectionFont,
|
||||
Dock = dock,
|
||||
AutoSize = dock == DockStyle.Left,
|
||||
Height = 32,
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
Padding = new Padding(0, 0, 12, 0),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Sends one browse request and renders its page. A request superseded by a newer one
|
||||
/// renders nothing; a failure clears the rows and shows the error in the status line.
|
||||
/// renders nothing; a failure clears the rows and shows the error in the summary line.
|
||||
/// Navigation follows only the latest request (<see cref="NestBrowseSession.IsLoading"/>).
|
||||
/// </summary>
|
||||
private async Task RunAsync(Func<Task<bool>> request)
|
||||
{
|
||||
statusLabel.Text = LoadingText;
|
||||
summaryLabel.Text = LoadingText;
|
||||
try
|
||||
{
|
||||
var started = request();
|
||||
@@ -183,8 +359,9 @@ public sealed class SavedNestsForm : Form
|
||||
{
|
||||
if (!IsDisposed)
|
||||
{
|
||||
grid.Rows.Clear();
|
||||
statusLabel.Text = $"Could not load nests from the server: {ex.Message}";
|
||||
nestGrid.Rows.Clear();
|
||||
summaryLabel.Text = $"Could not load nests from the server: {ex.Message}";
|
||||
UpdateTitle("");
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -193,8 +370,8 @@ public sealed class SavedNestsForm : Form
|
||||
{
|
||||
UpdateNavigation();
|
||||
// A request that was declined or superseded renders nothing of its own.
|
||||
if (!session.IsLoading && statusLabel.Text == LoadingText)
|
||||
statusLabel.Text = session.Summary;
|
||||
if (!session.IsLoading && summaryLabel.Text == LoadingText)
|
||||
summaryLabel.Text = session.Summary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,37 +382,57 @@ public sealed class SavedNestsForm : Form
|
||||
nextButton.Enabled = !session.IsLoading && session.CanGoNext;
|
||||
}
|
||||
|
||||
private void UpdateCommands()
|
||||
{
|
||||
var hasSelection = SelectedNestId() != null;
|
||||
openMenuItem.Enabled = hasSelection;
|
||||
deleteMenuItem.Enabled = hasSelection && !deleting;
|
||||
}
|
||||
|
||||
private void UpdateTitle(string summary)
|
||||
{
|
||||
var parts = new[] { WindowTitle, serverUrl, summary };
|
||||
Text = string.Join(" ", Array.FindAll(parts, part => part.Length > 0));
|
||||
}
|
||||
|
||||
private void Populate(NestPage page)
|
||||
{
|
||||
grid.Rows.Clear();
|
||||
foreach (var record in page.Items)
|
||||
nestGrid.Rows.Clear();
|
||||
for (var i = 0; i < page.Items.Count; i++)
|
||||
{
|
||||
grid.Rows.Add(
|
||||
var record = page.Items[i];
|
||||
var index = nestGrid.Rows.Add(
|
||||
record.Name,
|
||||
record.Customer,
|
||||
FormatStatus(record.Status),
|
||||
record.Material,
|
||||
record.DateCreated,
|
||||
record.DateModified,
|
||||
record.Thickness,
|
||||
record.PlateCount,
|
||||
record.PartCount,
|
||||
record.MadeBy,
|
||||
FormatDate(record.DateModified),
|
||||
record.PlateCount.ToString("N0", CultureInfo.CurrentCulture),
|
||||
record.PartCount.ToString("N0", CultureInfo.CurrentCulture),
|
||||
record.Comments,
|
||||
record.FileSize,
|
||||
record.SavedAt,
|
||||
record.Customer,
|
||||
record.MadeBy,
|
||||
record.Material,
|
||||
record.Thickness.ToString("0.####", CultureInfo.CurrentCulture),
|
||||
FormatDate(record.DateCreated),
|
||||
FormatDate(record.SavedAt),
|
||||
FormatFileSize(record.FileSize),
|
||||
record.Id
|
||||
);
|
||||
// Row numbers count through the whole filtered list, not just this page.
|
||||
nestGrid.Rows[index].HeaderCell.Value = (page.Offset + i + 1).ToString("N0", CultureInfo.CurrentCulture);
|
||||
}
|
||||
|
||||
foreach (DataGridViewColumn column in grid.Columns)
|
||||
foreach (DataGridViewColumn column in nestGrid.Columns)
|
||||
{
|
||||
column.HeaderCell.SortGlyphDirection = column.Tag is NestSortField field && field == session.Sort
|
||||
? session.Descending ? SortOrder.Descending : SortOrder.Ascending
|
||||
: SortOrder.None;
|
||||
}
|
||||
|
||||
statusLabel.Text = session.Summary;
|
||||
if (nestGrid.Rows.Count > 0 && nestGrid.CurrentCell == null)
|
||||
nestGrid.CurrentCell = nestGrid.Rows[0].Cells[0];
|
||||
|
||||
summaryLabel.Text = session.Summary;
|
||||
UpdateTitle(session.Summary);
|
||||
}
|
||||
|
||||
private static string FormatStatus(NestStatus status) => status switch
|
||||
@@ -246,23 +443,131 @@ public sealed class SavedNestsForm : Form
|
||||
_ => status.ToString(),
|
||||
};
|
||||
|
||||
private void Open_Click(object sender, EventArgs e)
|
||||
private static string FormatDate(DateTime value) =>
|
||||
value == default ? "" : value.ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
private static string FormatFileSize(long bytes) =>
|
||||
$"{System.Math.Ceiling(bytes / 1024.0).ToString("N0", CultureInfo.CurrentCulture)} KB";
|
||||
|
||||
private Guid? SelectedNestId() =>
|
||||
nestGrid.SelectedRows.Count > 0 && nestGrid.SelectedRows[0].Cells[IdColumn].Value is Guid id ? id : null;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the highlighted nest's details after a short pause, so arrowing through the
|
||||
/// list downloads only the nest the highlight settles on.
|
||||
/// </summary>
|
||||
private void ScheduleDetails()
|
||||
{
|
||||
if (grid.SelectedRows.Count == 0)
|
||||
detailsTimer.Stop();
|
||||
var id = SelectedNestId();
|
||||
if (id == null)
|
||||
{
|
||||
details.Clear();
|
||||
ShowDetailsMessage("");
|
||||
return;
|
||||
SelectedId = (Guid)grid.SelectedRows[0].Cells[IdColumn].Value;
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
|
||||
if (id == details.NestId)
|
||||
{
|
||||
// Back on the nest already shown or loading (for example after arrowing away and
|
||||
// back before the pause ended): keep that result rather than downloading it again.
|
||||
if (details.Details != null)
|
||||
{
|
||||
PopulateDetails(details.Details);
|
||||
return;
|
||||
}
|
||||
|
||||
if (details.IsLoading)
|
||||
{
|
||||
ShowDetailsMessage(LoadingDetailsText);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
ShowDetailsMessage(LoadingDetailsText);
|
||||
detailsTimer.Start();
|
||||
}
|
||||
|
||||
private async void Delete_Click(object sender, EventArgs e)
|
||||
private async Task ShowDetailsAsync(Guid id)
|
||||
{
|
||||
if (grid.SelectedRows.Count == 0)
|
||||
try
|
||||
{
|
||||
if (await details.LoadAsync(id) && !IsDisposed)
|
||||
PopulateDetails(details.Details);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (!IsDisposed)
|
||||
ShowDetailsMessage($"Could not load details: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Downloads one nest's archive and reads its plates and drawings off the UI thread.</summary>
|
||||
private async Task<NestDetails> LoadDetailsAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
var file = await repository.GetFileAsync(id, cancellationToken)
|
||||
?? throw new InvalidOperationException("This nest no longer exists on the server.");
|
||||
return await Task.Run(
|
||||
() =>
|
||||
{
|
||||
using var stream = new MemoryStream(file);
|
||||
return NestDetails.FromNest(new NestReader(stream).Read());
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private void ShowDetailsMessage(string message)
|
||||
{
|
||||
platesGrid.Rows.Clear();
|
||||
drawingsGrid.Rows.Clear();
|
||||
detailsStatus.Text = message;
|
||||
}
|
||||
|
||||
private void PopulateDetails(NestDetails nestDetails)
|
||||
{
|
||||
var unit = nestDetails.Units == Units.Millimeters ? "mm" : "in";
|
||||
platesGrid.Columns[PlateSizeColumn].HeaderText = $"Plate size ({unit})";
|
||||
drawingsGrid.Columns[AreaColumn].HeaderText = $"Area ({unit}\u00B2)";
|
||||
|
||||
ShowDetailsMessage(
|
||||
$"{nestDetails.Plates.Count:N0} plate(s), {nestDetails.Drawings.Count:N0} drawing(s)");
|
||||
foreach (var plate in nestDetails.Plates)
|
||||
{
|
||||
platesGrid.Rows.Add(
|
||||
plate.Number,
|
||||
plate.Duplicates,
|
||||
$"{plate.Width.ToString("0.###", CultureInfo.CurrentCulture)} x {plate.Length.ToString("0.###", CultureInfo.CurrentCulture)}",
|
||||
plate.PartCount,
|
||||
plate.DrawingCount,
|
||||
plate.Utilization);
|
||||
}
|
||||
|
||||
foreach (var drawing in nestDetails.Drawings)
|
||||
{
|
||||
drawingsGrid.Rows.Add(
|
||||
drawing.Name,
|
||||
drawing.Customer,
|
||||
drawing.Required,
|
||||
drawing.Nested,
|
||||
drawing.Remaining,
|
||||
drawing.Area);
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenSelected()
|
||||
{
|
||||
if (SelectedNestId() is not Guid id)
|
||||
return;
|
||||
SelectedId = id;
|
||||
DialogResult = DialogResult.OK;
|
||||
}
|
||||
|
||||
private async Task DeleteSelectedAsync()
|
||||
{
|
||||
if (deleting || SelectedNestId() is not Guid id)
|
||||
return;
|
||||
|
||||
var id = (Guid)grid.SelectedRows[0].Cells[IdColumn].Value;
|
||||
var name = grid.SelectedRows[0].Cells["Name"].Value?.ToString() ?? "";
|
||||
|
||||
var name = nestGrid.SelectedRows[0].Cells[0].Value?.ToString() ?? "";
|
||||
var confirm = MessageBox.Show(
|
||||
this,
|
||||
$"Delete '{name}' from the server? This cannot be undone.",
|
||||
@@ -273,7 +578,8 @@ public sealed class SavedNestsForm : Form
|
||||
if (confirm != DialogResult.Yes)
|
||||
return;
|
||||
|
||||
deleteButton.Enabled = false;
|
||||
deleting = true;
|
||||
UpdateCommands();
|
||||
try
|
||||
{
|
||||
await repository.DeleteAsync(id);
|
||||
@@ -293,8 +599,9 @@ public sealed class SavedNestsForm : Form
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleting = false;
|
||||
if (!IsDisposed)
|
||||
deleteButton.Enabled = grid.SelectedRows.Count > 0;
|
||||
UpdateCommands();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +610,9 @@ public sealed class SavedNestsForm : Form
|
||||
if (disposing)
|
||||
{
|
||||
searchTimer.Dispose();
|
||||
detailsTimer.Dispose();
|
||||
session.Dispose();
|
||||
details.Dispose();
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
|
||||
+17
-12
@@ -4,19 +4,24 @@ The desktop app can save nests two ways:
|
||||
|
||||
- **File mode** (default, unchanged behavior): Save/Save As write a `.nest` ZIP
|
||||
archive to disk via the normal file dialog. See [nest-file-format.md](nest-file-format.md).
|
||||
- **Database mode**: Open browses server records 100 at a time. The filter box
|
||||
- **Database mode**: Open browses server records 100 at a time. The Find box
|
||||
searches name, customer, material, made by, comments and status on the server
|
||||
after a short typing pause; clicking a column header sorts every match on the
|
||||
server (click again to reverse); Previous/Next move between pages and the
|
||||
status line shows the range and total. Dates and numbers are not searched as
|
||||
text; sort their column instead. Save creates a record on first save and
|
||||
updates that same record afterward. Save As creates a new record (a copy). A failed save keeps
|
||||
the previous record association; switching server URLs creates a record on the
|
||||
new server rather than updating an id from the previous one. Delete in the
|
||||
saved-nest list permanently removes the selected server record after confirmation.
|
||||
A separate **File > Export .nest...** command is always available (in both
|
||||
modes) for producing a local file to share or back up. Export does not change
|
||||
the document's file save path or database record association.
|
||||
after a short typing pause (Enter in the box searches at once); clicking a
|
||||
column header sorts every match on the server (click again to reverse); the
|
||||
arrow buttons move between pages and the title and find bar show the range and
|
||||
total. Dates and numbers are not searched as text; sort their column instead.
|
||||
Below the list, the **Details** tabs show the highlighted nest's plates
|
||||
(duplicates, size, parts, drawings, utilization) and drawings (required, nested,
|
||||
remaining, area); they are read from that nest's archive, which is downloaded
|
||||
shortly after the highlight stops moving. Enter or a double-click opens the
|
||||
highlighted nest and Esc closes. Save creates a record on first save and updates
|
||||
that same record afterward. Save As creates a new record (a copy). A failed save
|
||||
keeps the previous record association; switching server URLs creates a record on
|
||||
the new server rather than updating an id from the previous one. Delete on the
|
||||
Open dialog's right-click menu permanently removes the highlighted server record
|
||||
after confirmation. A separate **File > Export .nest...** command is always
|
||||
available (in both modes) for producing a local file to share or back up. Export
|
||||
does not change the document's file save path or database record association.
|
||||
|
||||
The mode and server address are stored per-PC at `%APPDATA%\OpenNest\storage.json`
|
||||
(`OpenNest.Data.NestStorageSettings`), defaulting to File mode so existing
|
||||
|
||||
Reference in new issue
Block a user