From dd17479a83198d59eadaf621ab331aab3d514acd Mon Sep 17 00:00:00 2001 From: Olipro Date: Thu, 21 May 2026 16:59:30 +0200 Subject: [PATCH] Yet another ChooserPanel rework. This reworks the behaviour and sizing of the ChooserPanel and icon grid to make it size itself properly (along with its buttons) - a good chunk of tests have been added which hopefully should prevent any regressions. Closes #170 --- Foreman/Controls/ChooserIconGrid.cs | 22 +- Foreman/Controls/ChooserLayout.cs | 14 + Foreman/Controls/IRChooserPanel.Designer.cs | 26 +- Foreman/Controls/IRChooserPanel.Ui.cs | 697 +++++++++++++++--- Foreman/Controls/IRChooserPanel.cs | 69 +- Foreman/Forms/MainForm.Designer.cs | 3 +- .../EditPanelScreenLayout.cs | 8 + .../Elements/DraggedLinkElement.cs | 3 +- .../ProductionGraphViewer.cs | 6 +- ForemanTest/ChooserLayoutTests.cs | 125 +++- ForemanTest/ProductionGraphViewerEditTests.cs | 288 +++++++- 11 files changed, 1128 insertions(+), 133 deletions(-) diff --git a/Foreman/Controls/ChooserIconGrid.cs b/Foreman/Controls/ChooserIconGrid.cs index cde4ac6..3d19a10 100644 --- a/Foreman/Controls/ChooserIconGrid.cs +++ b/Foreman/Controls/ChooserIconGrid.cs @@ -78,10 +78,24 @@ public void ApplyDesignLayout() { public void WireMouseWheel(MouseEventHandler handler) => gridSurface.MouseWheel += handler; /// Size the grid to fit the allotted area; returns the outer width (cells + scrollbar). - public int ApplyLayout(int availableGridHeight, int maxLayoutWidth, int designCellSize, int minCellSize, int scrollbarWidth) { - int cellByHeight = availableGridHeight / VisibleRowCount; + public int ApplyLayout(int availableGridHeight, int maxLayoutWidth, int designCellSize, int minCellSize, int scrollbarWidth, int minOuterWidth = 0) { + int minGridHeight = minCellSize * VisibleRowCount; + int cellByHeight = Math.Max(1, availableGridHeight / VisibleRowCount); int cellByWidth = Math.Max(1, (maxLayoutWidth - scrollbarWidth) / ColumnCount); - int cell = Math.Max(minCellSize, Math.Min(designCellSize, Math.Min(cellByHeight, cellByWidth))); + int cell = Math.Min(designCellSize, Math.Min(cellByHeight, cellByWidth)); + if (availableGridHeight >= minGridHeight) + cell = Math.Max(minCellSize, cell); + else + cell = Math.Max(1, cell); + + if (minOuterWidth > 0) { + int cellForMinOuter = (int)Math.Ceiling((minOuterWidth - scrollbarWidth) / (double)ColumnCount); + cellForMinOuter = Math.Max(minCellSize, Math.Min(designCellSize, cellForMinOuter)); + if (cellForMinOuter * ColumnCount + scrollbarWidth <= maxLayoutWidth) { + int cellByHeightCap = Math.Max(1, availableGridHeight / VisibleRowCount); + cell = Math.Max(cell, Math.Min(cellForMinOuter, cellByHeightCap)); + } + } TargetCellSize = cell; int gridHeight = cell * VisibleRowCount; @@ -97,7 +111,7 @@ public int ApplyLayout(int availableGridHeight, int maxLayoutWidth, int designCe MaximumSize = new Size(outerWidth, gridHeight); ApplyCellGridBounds(gridWidth, gridHeight, scrollbarWidth); - ResumeLayout(true); + ResumeLayout(performLayout: false); return outerWidth; } diff --git a/Foreman/Controls/ChooserLayout.cs b/Foreman/Controls/ChooserLayout.cs index 36268cd..780b092 100644 --- a/Foreman/Controls/ChooserLayout.cs +++ b/Foreman/Controls/ChooserLayout.cs @@ -19,6 +19,10 @@ internal static class ChooserLayout { public const int DesignItemIconPixels = 40; public const int DesignMinCellPixels = 18; public const int DesignMinGroupIconPixels = 24; + public const int DesignFooterButtonHeightPixels = 38; + public const int DesignMinFooterButtonHeightPixels = 22; + public const float DesignFooterButtonFontSizePoints = 8.25f; + public const float DesignMinFooterButtonFontSizePoints = 6f; public const int DesignMinVisibleRows = 4; public static int DesignGridOuterWidth => @@ -43,5 +47,15 @@ public static int GroupIconSizeForCell(int cellSize, int designGroupSize, int mi int fromCell = (int)Math.Round(cellSize * (DesignGroupIconPixels / (float)DesignCellPixels)); return Math.Min(designGroupSize, Math.Max(minGroupSize, fromCell)); } + + public static int FooterButtonHeightForCell(int cellSize, int designFooterHeight, int minFooterHeight) { + int fromCell = (int)Math.Round(cellSize * (DesignFooterButtonHeightPixels / (float)DesignCellPixels)); + return Math.Max(minFooterHeight, Math.Min(designFooterHeight, fromCell)); + } + + public static float FooterButtonFontSizeForCell(int cellSize, int designCellPixels, float designFontSize, float minFontSize) { + float fromCell = cellSize * (designFontSize / designCellPixels); + return Math.Max(minFontSize, Math.Min(designFontSize, fromCell)); + } } } diff --git a/Foreman/Controls/IRChooserPanel.Designer.cs b/Foreman/Controls/IRChooserPanel.Designer.cs index 7d2e547..98d9c8b 100644 --- a/Foreman/Controls/IRChooserPanel.Designer.cs +++ b/Foreman/Controls/IRChooserPanel.Designer.cs @@ -32,6 +32,7 @@ private void InitializeComponent() { AsFuelCheckBox = new CheckBox(); ItemIconPanel = new Panel(); groupsPanel = new FlowLayoutPanel(); + iconGridBand = new Panel(); iconGrid = new ChooserIconGrid(); nodeOptionsRowA = new FlowLayoutPanel(); AddSupplyButton = new Button(); @@ -56,10 +57,12 @@ private void InitializeComponent() { // contentStack.Controls.Add(headerStack); contentStack.Controls.Add(groupsPanel); - contentStack.Controls.Add(iconGrid); + iconGridBand.Controls.Add(iconGrid); + contentStack.Controls.Add(iconGridBand); contentStack.Controls.Add(nodeOptionsRowA); contentStack.Controls.Add(nodeOptionsRowB); contentStack.AutoSize = false; + contentStack.BackColor = Color.DimGray; contentStack.FlowDirection = FlowDirection.TopDown; contentStack.Location = new Point(0, 0); contentStack.Margin = new Padding(0); @@ -279,8 +282,10 @@ private void InitializeComponent() { // // groupsPanel // - groupsPanel.AutoSize = true; - groupsPanel.AutoSizeMode = AutoSizeMode.GrowAndShrink; + groupsPanel.AutoSize = false; + groupsPanel.AutoSizeMode = AutoSizeMode.GrowOnly; + groupsPanel.FlowDirection = FlowDirection.LeftToRight; + groupsPanel.WrapContents = true; groupsPanel.BackColor = Color.DimGray; groupsPanel.Location = new Point(0, 195); groupsPanel.Margin = new Padding(0); @@ -289,15 +294,25 @@ private void InitializeComponent() { groupsPanel.Size = new Size(8, 5); groupsPanel.TabIndex = 1; // + // iconGridBand + // + iconGridBand.BackColor = Color.DimGray; + iconGridBand.Controls.Add(iconGrid); + iconGridBand.Location = new Point(0, 200); + iconGridBand.Margin = new Padding(0); + iconGridBand.Name = "iconGridBand"; + iconGridBand.Size = new Size(421, 320); + iconGridBand.TabIndex = 2; + // // iconGrid // iconGrid.BackColor = Color.DimGray; - iconGrid.Location = new Point(0, 200); + iconGrid.Location = new Point(0, 0); iconGrid.Margin = new Padding(0); iconGrid.MinimumSize = new Size(421, 320); iconGrid.Name = "iconGrid"; iconGrid.Size = new Size(421, 320); - iconGrid.TabIndex = 2; + iconGrid.TabIndex = 0; // // nodeOptionsRowA // @@ -447,6 +462,7 @@ private void InitializeComponent() { private FlowLayoutPanel filterRow; private FlowLayoutPanel optionRow; protected FlowLayoutPanel groupsPanel; + private Panel iconGridBand; protected ChooserIconGrid iconGrid; protected FlowLayoutPanel nodeOptionsRowA; protected FlowLayoutPanel nodeOptionsRowB; diff --git a/Foreman/Controls/IRChooserPanel.Ui.cs b/Foreman/Controls/IRChooserPanel.Ui.cs index 08ff79a..58449a1 100644 --- a/Foreman/Controls/IRChooserPanel.Ui.cs +++ b/Foreman/Controls/IRChooserPanel.Ui.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; using System.Drawing; +using System.Linq; using System.Windows.Forms; namespace Foreman { @@ -12,6 +13,8 @@ public partial class IRChooserPanel { private ScaledChooserMetrics scaledMetrics; private bool applyingViewerBounds; + private bool refreshingViewerBounds; + private Font? scaledFooterButtonFont; private readonly record struct ScaledChooserMetrics { public int DesignCell { get; init; } @@ -21,9 +24,19 @@ private readonly record struct ScaledChooserMetrics { public int DesignWidth { get; init; } public int MinGridHeight { get; init; } public int DesignGridHeight { get; init; } + public int DesignFooterButton { get; init; } + public int MinFooterButton { get; init; } + public float DesignFooterFont { get; init; } + public float MinFooterFont { get; init; } public int GroupSizeForCell(int cellSize) => ChooserLayout.GroupIconSizeForCell(cellSize, DesignGroup, MinGroup); + + public int FooterButtonHeightForCell(int cellSize) => + ChooserLayout.FooterButtonHeightForCell(cellSize, DesignFooterButton, MinFooterButton); + + public float FooterButtonFontSizeForCell(int cellSize) => + ChooserLayout.FooterButtonFontSizeForCell(cellSize, DesignCell, DesignFooterFont, MinFooterFont); } private void ApplyDpiScaling() { @@ -33,8 +46,13 @@ private void ApplyDpiScaling() { MinGroup = ChooserLayout.Scale(this, ChooserLayout.DesignMinGroupIconPixels), DesignGroup = ChooserLayout.Scale(this, ChooserLayout.DesignGroupIconPixels), DesignWidth = ChooserLayout.Scale(this, ChooserLayout.DesignChooserWidth), - MinGridHeight = ChooserLayout.Scale(this, ChooserLayout.DesignMinCellPixels) * ChooserLayout.DesignMinVisibleRows, + MinGridHeight = ChooserLayout.Scale(this, ChooserLayout.DesignMinCellPixels) * ChooserIconGrid.VisibleRowCount, DesignGridHeight = ChooserLayout.Scale(this, ChooserLayout.DesignCellPixels) * ChooserIconGrid.VisibleRowCount, + DesignFooterButton = ChooserLayout.Scale(this, ChooserLayout.DesignFooterButtonHeightPixels), + MinFooterButton = ChooserLayout.Scale(this, ChooserLayout.DesignMinFooterButtonHeightPixels), + DesignFooterFont = AddPassthroughButton.Font.Size, + MinFooterFont = AddPassthroughButton.Font.Size * ChooserLayout.DesignMinFooterButtonFontSizePoints + / ChooserLayout.DesignFooterButtonFontSizePoints, }; FilterTextBox.Width = ChooserLayout.Scale(this, ChooserLayout.DesignFilterTextWidth); @@ -60,57 +78,312 @@ private void ApplyGroupLayout(int groupButtonSize) { groupsPanel.PerformLayout(); } - private int MeasureHeaderFooterHeight() { - int height = 0; - headerStack.PerformLayout(); - if (headerStack.Visible) { - Size header = headerStack.GetPreferredSize(Size.Empty); - height += Math.Max(header.Height, headerStack.Height); + private int MeasureHeaderFooterHeight(int rowWidth) { + if (!headerStack.Visible) + return 0; + SetFlowRowSize(headerStack, rowWidth); + return headerStack.Height; + } + + private void LayoutGroupsPanelSize(int width, int maxHeight = int.MaxValue) { + groupsPanel.AutoSize = false; + groupsPanel.WrapContents = true; + if (!groupsPanel.Visible || groupsPanel.Controls.Count == 0) { + groupsPanel.AutoScroll = false; + groupsPanel.Size = new Size(Math.Max(1, width), 0); + return; + } + groupsPanel.Width = Math.Max(1, width); + groupsPanel.PerformLayout(); + + int contentBottom = groupsPanel.Padding.Top; + foreach (Control control in groupsPanel.Controls) { + if (control.Visible) + contentBottom = Math.Max(contentBottom, control.Bottom); + } + + int height = Math.Max(1, contentBottom + groupsPanel.Padding.Bottom); + if (height > maxHeight) { + groupsPanel.AutoScroll = true; + height = Math.Max(1, maxHeight); + } else { + groupsPanel.AutoScroll = false; + } + groupsPanel.Size = new Size(Math.Max(1, width), height); + } + + private int ResolveFooterButtonHeight(in ScaledChooserMetrics metrics) { + if (iconGrid.Visible && iconGrid.TargetCellSize > 0) + return metrics.FooterButtonHeightForCell(iconGrid.TargetCellSize); + return metrics.DesignFooterButton; + } + + private float ResolveFooterButtonFontSize(in ScaledChooserMetrics metrics) { + if (iconGrid.Visible && iconGrid.TargetCellSize > 0) + return metrics.FooterButtonFontSizeForCell(iconGrid.TargetCellSize); + return metrics.DesignFooterFont; + } + + private Font GetOrCreateFooterButtonFont(float fontSize) { + if (scaledFooterButtonFont != null && Math.Abs(scaledFooterButtonFont.Size - fontSize) < 0.01f) + return scaledFooterButtonFont; + Font designFont = AddPassthroughButton.Font; + scaledFooterButtonFont?.Dispose(); + scaledFooterButtonFont = new Font(designFont.FontFamily, fontSize, designFont.Style, GraphicsUnit.Point); + return scaledFooterButtonFont; + } + + private void DisposeScaledFooterButtonFont() { + scaledFooterButtonFont?.Dispose(); + scaledFooterButtonFont = null; + } + + private void ApplyFooterChromeLayout(int panelWidth, in ScaledChooserMetrics metrics) { + int footerButtonHeight = ResolveFooterButtonHeight(metrics); + if (footerButtonHeight <= 0) + return; + float footerFont = ResolveFooterButtonFontSize(metrics); + int buttonLayoutWidth = ResolveFooterButtonLayoutWidth(panelWidth, footerButtonHeight, footerFont); + LayoutFooterRows(panelWidth, buttonLayoutWidth, footerButtonHeight, footerFont); + } + + /// Re-apply footer and icon-grid band after WinForms perform-layout. + private void SyncFooterButtonsToGridCell() { + if (PGViewer == null || iconGrid.TargetCellSize < 1) + return; + const int margin = EditPanelScreenLayout.DefaultMargin; + int maxWidth = Math.Max(1, PGViewer.ClientSize.Width - margin * 2); + ScaledChooserMetrics metrics = GetScaledMetrics(); + // Use TargetCellSize directly; ResolveFooter* can still reflect design metrics mid-layout. + int cell = iconGrid.TargetCellSize; + int footerHeight = metrics.FooterButtonHeightForCell(cell); + float footerFont = metrics.FooterButtonFontSizeForCell(cell); + int panelWidth = ResolvePanelContentWidth(maxWidth, metrics); + int buttonLayoutWidth = ResolveFooterButtonLayoutWidth(panelWidth, footerHeight, footerFont); + SuspendLayout(); + contentStack.SuspendLayout(); + try { + LayoutFooterRows(panelWidth, buttonLayoutWidth, footerHeight, footerFont); + LayoutIconGridBand(panelWidth); + } finally { + contentStack.ResumeLayout(performLayout: false); + ResumeLayout(performLayout: false); + } + } + + private static int MeasureFooterButtonNaturalWidth(Control button, Font font, int buttonHeight) { + if (button is not Button textButton) + return Math.Max(1, button.Width); + const int horizontalChrome = 16; + Size text = TextRenderer.MeasureText( + textButton.Text, + font, + new Size(int.MaxValue, Math.Max(1, buttonHeight)), + TextFormatFlags.SingleLine | TextFormatFlags.LeftAndRightPadding); + return Math.Max(1, text.Width + horizontalChrome + button.Margin.Horizontal); + } + + private int MeasureFooterButtonsNaturalWidth(int buttonHeight, float fontSize) { + Font font = GetOrCreateFooterButtonFont(fontSize); + int width = 0; + foreach (FlowLayoutPanel row in new[] { nodeOptionsRowA, nodeOptionsRowB }) { + if (!row.Visible) + continue; + int rowWidth = row.Padding.Horizontal; + foreach (Control button in row.Controls.Cast().Where(c => c.Visible)) + rowWidth += MeasureFooterButtonNaturalWidth(button, font, buttonHeight); + width = Math.Max(width, rowWidth); + } + return width; + } + + private int ResolveFooterButtonLayoutWidth(int panelWidth, int buttonHeight, float fontSize) { + int natural = MeasureFooterButtonsNaturalWidth(buttonHeight, fontSize); + if (!iconGrid.Visible) + return Math.Min(panelWidth, natural); + return Math.Min(panelWidth, Math.Max(iconGrid.Width, natural)); + } + + private void LayoutNodeOptionsRow( + FlowLayoutPanel row, int panelRowWidth, int buttonLayoutWidth, int buttonHeight, float fontSize) { + row.AutoSize = false; + row.AutoSizeMode = AutoSizeMode.GrowOnly; + row.WrapContents = false; + if (!row.Visible) { + row.Size = new Size(Math.Max(1, panelRowWidth), 0); + return; } + + var buttons = row.Controls.Cast().Where(c => c.Visible).ToList(); + if (buttons.Count == 0) { + row.Size = new Size(Math.Max(1, panelRowWidth), 0); + return; + } + + Font font = GetOrCreateFooterButtonFont(fontSize); + var naturalWidths = buttons.Select(b => MeasureFooterButtonNaturalWidth(b, font, buttonHeight)).ToList(); + int totalMargin = buttons.Sum(b => b.Margin.Horizontal); + int naturalTotal = naturalWidths.Sum() + totalMargin; + int innerWidth = Math.Max(1, panelRowWidth - row.Padding.Horizontal); + int clusterWidth = Math.Min(innerWidth, Math.Max(naturalTotal, buttonLayoutWidth)); + int extra = Math.Max(0, innerWidth - clusterWidth); + int x = row.Padding.Left + extra / 2; + + row.SuspendLayout(); + try { + for (int i = 0; i < buttons.Count; i++) { + Control button = buttons[i]; + int width = naturalTotal > innerWidth + ? Math.Max(1, (innerWidth - totalMargin) / buttons.Count) + : naturalWidths[i]; + button.AutoSize = false; + button.Font = font; + var buttonSize = new Size(width, buttonHeight); + button.MinimumSize = buttonSize; + button.MaximumSize = buttonSize; + button.Size = buttonSize; + button.Location = new Point(x, row.Padding.Top); + x += width + button.Margin.Horizontal; + } + + int rowHeight = Math.Max(1, buttonHeight + row.Padding.Vertical); + row.Size = new Size(Math.Max(1, panelRowWidth), rowHeight); + } finally { + row.ResumeLayout(performLayout: false); + } + } + + private void LayoutFooterRows(int panelRowWidth, int buttonLayoutWidth, int buttonHeight, float fontSize) { if (nodeOptionsRowA.Visible) - height += nodeOptionsRowA.PreferredSize.Height; + LayoutNodeOptionsRow(nodeOptionsRowA, panelRowWidth, buttonLayoutWidth, buttonHeight, fontSize); if (nodeOptionsRowB.Visible) - height += nodeOptionsRowB.PreferredSize.Height; + LayoutNodeOptionsRow(nodeOptionsRowB, panelRowWidth, buttonLayoutWidth, buttonHeight, fontSize); + } + + private void LayoutIconGridBand(int panelWidth) { + if (!iconGrid.Visible) { + iconGridBand.Visible = false; + iconGridBand.Size = Size.Empty; + return; + } + iconGridBand.Visible = true; + int bandWidth = Math.Max(iconGrid.Width, panelWidth); + iconGridBand.Size = new Size(bandWidth, iconGrid.Height); + iconGrid.Location = new Point(Math.Max(0, (bandWidth - iconGrid.Width) / 2), 0); + } + + private int MeasureFooterChromeHeight(int panelRowWidth, int buttonHeight, float fontSize) { + int buttonLayoutWidth = ResolveFooterButtonLayoutWidth(panelRowWidth, buttonHeight, fontSize); + LayoutFooterRows(panelRowWidth, buttonLayoutWidth, buttonHeight, fontSize); + int height = 0; + if (nodeOptionsRowA.Visible) + height += nodeOptionsRowA.Height; + if (nodeOptionsRowB.Visible) + height += nodeOptionsRowB.Height; return height; } - private int MeasureGroupsPanelHeight(int layoutWidth) { + private int ResolveGroupsPanelMaxHeight(int maxPanelHeight, int rowWidth, in ScaledChooserMetrics metrics) { + if (!groupsPanel.Visible) + return int.MaxValue; + if (headerStack.Visible) + SetFlowRowSize(headerStack, rowWidth); + int footerButtonHeight = ResolveFooterButtonHeight(metrics); + float footerFont = ResolveFooterButtonFontSize(metrics); + int chrome = MeasureHeaderFooterHeight(rowWidth) + MeasureFooterChromeHeight(rowWidth, footerButtonHeight, footerFont); + return Math.Max(metrics.MinGroup, maxPanelHeight - chrome - metrics.MinGridHeight); + } + + private int MeasureGroupsPanelHeight(int layoutWidth, int groupSize) { if (!groupsPanel.Visible || groupsPanel.Controls.Count == 0) return 0; - groupsPanel.PerformLayout(); - return groupsPanel.GetPreferredSize(new Size(layoutWidth, 0)).Height; + ApplyGroupLayout(groupSize); + LayoutGroupsPanelSize(layoutWidth); + return groupsPanel.Height; } - private int MeasureChromeHeight(int layoutWidth, int groupSize) { + private int MeasureLaidOutChromeHeight(int maxWidth, int groupSize, int maxPanelHeight, in ScaledChooserMetrics metrics) { ApplyGroupLayout(groupSize); - return MeasureHeaderFooterHeight() + MeasureGroupsPanelHeight(layoutWidth); + SyncChromeRowWidths(ResolvePanelContentWidth(maxWidth, metrics), groupSize, maxPanelHeight, metrics); + return SumVisibleHeights(headerStack, groupsPanel, nodeOptionsRowA, nodeOptionsRowB); } private int MeasureMinimumPanelHeight(int layoutWidth, int restoreGroupSize, in ScaledChooserMetrics metrics) { ApplyGroupLayout(metrics.MinGroup); try { - return MeasureHeaderFooterHeight() + MeasureGroupsPanelHeight(layoutWidth); + return MeasureHeaderFooterHeight(layoutWidth) + MeasureGroupsPanelHeight(layoutWidth, metrics.MinGroup); } finally { ApplyGroupLayout(restoreGroupSize); } } - private int MeasureHeaderIntrinsicMinWidth() { - if (!headerStack.Visible) + private static int MeasureFlowRowNaturalWidth(FlowLayoutPanel row) { + if (!row.Visible) return 0; - headerStack.PerformLayout(); - return headerStack.GetPreferredSize(Size.Empty).Width; + row.PerformLayout(); + int width = row.Padding.Horizontal; + foreach (Control control in row.Controls.Cast().Where(c => c.Visible)) { + Size pref = control.AutoSize ? control.PreferredSize : control.Size; + width += pref.Width + control.Margin.Horizontal; + } + return width; } - private int MeasureHeaderMinWidth(int layoutWidth) { + private int MeasureHeaderIntrinsicMinWidth() { if (!headerStack.Visible) return 0; - headerStack.PerformLayout(); - return headerStack.GetPreferredSize(new Size(Math.Max(1, layoutWidth), 0)).Width; + int width = headerStack.Padding.Horizontal; + foreach (Control child in headerStack.Controls) { + if (!child.Visible) + continue; + if (child is FlowLayoutPanel row) + width = Math.Max(width, MeasureFlowRowNaturalWidth(row)); + else + width = Math.Max(width, child.Width + child.Margin.Horizontal); + } + return width; + } + + private int MeasureLaidOutPanelWidth() { + int width = 0; + if (headerStack.Visible) { + foreach (Control row in headerStack.Controls) { + if (!row.Visible) + continue; + foreach (Control control in row.Controls) { + if (!control.Visible) + continue; + width = Math.Max(width, control.Right + row.Padding.Right); + } + } + width += headerStack.Padding.Horizontal; + } + if (iconGrid.Visible) + width = Math.Max(width, iconGridBand.Width); + foreach (FlowLayoutPanel row in new[] { nodeOptionsRowA, nodeOptionsRowB }) { + if (!row.Visible) + continue; + width = Math.Max(width, row.Width); + } + return width; } - private int MeasureContentWidth() { - return iconGrid.Visible ? iconGrid.Width : MeasureHeaderIntrinsicMinWidth(); + private int MeasureContentWidth(int maxWidth = int.MaxValue, in ScaledChooserMetrics metrics = default) { + int width = iconGrid.Visible ? iconGrid.Width : 0; + if (metrics.DesignCell > 0) { + int footerHeight = ResolveFooterButtonHeight(metrics); + float footerFont = ResolveFooterButtonFontSize(metrics); + width = Math.Max(width, MeasureFooterButtonsNaturalWidth(footerHeight, footerFont)); + } + width = Math.Max(width, MeasureHeaderIntrinsicMinWidth()); + return Math.Min(maxWidth, width); + } + + private int ResolvePanelContentWidth(int maxWidth, in ScaledChooserMetrics metrics) { + int width = MeasureContentWidth(maxWidth, metrics); + if (IsHandleCreated && headerStack.Visible) + width = Math.Max(width, MeasureLaidOutPanelWidth()); + return Math.Min(maxWidth, width); } private static void SetFlowRowSize(FlowLayoutPanel row, int width) { @@ -119,14 +392,21 @@ private static void SetFlowRowSize(FlowLayoutPanel row, int width) { row.Size = new Size(width, Math.Max(1, pref.Height)); } - private void SyncChromeRowWidths(int contentWidth) { + private void SyncChromeRowWidths(int contentWidth, int groupSize, int maxPanelHeight = int.MaxValue, in ScaledChooserMetrics metrics = default) { if (contentWidth < 1) return; - FlowLayoutPanel[] rows = [headerStack, groupsPanel, nodeOptionsRowA, nodeOptionsRowB]; - foreach (FlowLayoutPanel row in rows) { - if (row.Visible) - SetFlowRowSize(row, contentWidth); + ApplyGroupLayout(groupSize); + if (headerStack.Visible) + SetFlowRowSize(headerStack, contentWidth); + if (groupsPanel.Visible) { + int maxGroupsHeight = metrics.DesignCell > 0 + ? ResolveGroupsPanelMaxHeight(maxPanelHeight, contentWidth, metrics) + : int.MaxValue; + LayoutGroupsPanelSize(contentWidth, maxGroupsHeight); } + if (iconGrid.Visible) + LayoutIconGridBand(contentWidth); + ApplyFooterChromeLayout(contentWidth, metrics); } private static int SumVisibleHeights(params Control[] rows) { @@ -138,39 +418,45 @@ private static int SumVisibleHeights(params Control[] rows) { return height; } - private Size MeasureContentSize(int groupSize) { + private Size MeasureContentSize(ref int groupSize, int maxWidth, int maxPanelHeight, in ScaledChooserMetrics metrics) { ApplyGroupLayout(groupSize); - int width = MeasureContentWidth(); - SyncChromeRowWidths(width); - int height = SumVisibleHeights(headerStack, groupsPanel, iconGrid, nodeOptionsRowA, nodeOptionsRowB); - return new Size(width, height); + SyncChromeRowWidths(ResolvePanelContentWidth(maxWidth, metrics), groupSize, maxPanelHeight, metrics); + int height = SumVisibleHeights(headerStack, groupsPanel, iconGridBand, nodeOptionsRowA, nodeOptionsRowB); + return new Size(ResolvePanelContentWidth(maxWidth, metrics), height); } - private void WidenGridToMinimumWidthIfPossible(int maxHeight, int maxWidth, int minWidth, ref int groupSize, in ScaledChooserMetrics metrics) { - if (!iconGrid.Visible || minWidth <= iconGrid.Width || minWidth > maxWidth) + private void WidenGridToMinimumWidthIfPossible(int maxHeight, int maxWidth, ref int groupSize, in ScaledChooserMetrics metrics) { + if (!iconGrid.Visible) return; - int gridHeight = AvailableGridHeight(maxHeight, minWidth, groupSize, metrics); - int outer = FitGridAndWidth(maxWidth, gridHeight, minWidth, metrics); + int minGridOuter = ChooserLayout.DesignMinVisibleRows * metrics.MinCell + GetScrollbarWidth(); + if (minGridOuter <= iconGrid.Width || minGridOuter > maxWidth) + return; + int gridHeight = AvailableGridHeight(maxHeight, minGridOuter, groupSize, metrics); + int outer = FitGridAndWidth(maxWidth, gridHeight, minGridOuter, metrics); if (outer > iconGrid.Width) groupSize = metrics.GroupSizeForCell(iconGrid.TargetCellSize); } - private int AvailableGridHeight(int maxPanelHeight, int layoutWidth, int groupSize, in ScaledChooserMetrics metrics) { - int chrome = MeasureChromeHeight(layoutWidth, groupSize); - return Math.Max(metrics.MinGridHeight, Math.Min(metrics.DesignGridHeight, Math.Max(1, maxPanelHeight - chrome))); + private int AvailableGridHeight(int maxPanelHeight, int maxWidth, int groupSize, in ScaledChooserMetrics metrics) { + int chrome = MeasureLaidOutChromeHeight(maxWidth, groupSize, maxPanelHeight, metrics); + return Math.Max(1, Math.Min(metrics.DesignGridHeight, Math.Max(1, maxPanelHeight - chrome))); } - private int FitGridAndWidth(int maxWidth, int gridHeight, int width, in ScaledChooserMetrics metrics) { - int outerWidth = iconGrid.ApplyLayout(gridHeight, width, metrics.DesignCell, metrics.MinCell, GetScrollbarWidth()); - int headerMin = MeasureHeaderIntrinsicMinWidth(); - int maxOuter = Math.Min(metrics.DesignWidth, maxWidth); - if (headerMin > outerWidth && headerMin <= maxOuter) { - width = headerMin; - outerWidth = iconGrid.ApplyLayout(gridHeight, width, metrics.DesignCell, metrics.MinCell, GetScrollbarWidth()); + /// Largest group icon size that still leaves room for at least a minimal recipe grid. + private int ResolveMaxGroupSizeForHeight(int maxHeight, int maxWidth, in ScaledChooserMetrics metrics) { + for (int size = metrics.DesignGroup; size >= metrics.MinGroup; size--) { + if (MeasureLaidOutChromeHeight(maxWidth, size, maxHeight, metrics) + metrics.MinGridHeight <= maxHeight) + return size; } - return outerWidth; + return metrics.MinGroup; } + private static int CapGroupSize(int groupSize, int maxGroupSize) => Math.Min(groupSize, maxGroupSize); + + private int FitGridAndWidth(int maxWidth, int gridHeight, int minOuterWidth, in ScaledChooserMetrics metrics) => + iconGrid.ApplyLayout( + gridHeight, maxWidth, metrics.DesignCell, metrics.MinCell, GetScrollbarWidth(), minOuterWidth); + private (int width, int groupSize) ReflowGridAndTieGroupSize( int maxHeight, int maxWidth, int width, int groupSize, in ScaledChooserMetrics metrics) { int gridHeight = AvailableGridHeight(maxHeight, width, groupSize, metrics); @@ -178,29 +464,203 @@ private int FitGridAndWidth(int maxWidth, int gridHeight, int width, in ScaledCh return (width, metrics.GroupSizeForCell(iconGrid.TargetCellSize)); } - private void ExpandGridForHeaderIfNeeded(int maxHeight, int maxWidth, ref int groupSize, in ScaledChooserMetrics metrics) { + /// Binary-search grid height so total panel height fits (no viewport scroll). + private Size FitPanelHeightToViewer( + int maxHeight, int maxWidth, ref int groupSize, in ScaledChooserMetrics metrics) { if (!iconGrid.Visible) + return MeasureContentSize(ref groupSize, maxWidth, maxHeight, metrics); + + int layoutWidth = ResolvePanelContentWidth(maxWidth, metrics); + int maxGroupSize = ResolveMaxGroupSizeForHeight(maxHeight, maxWidth, metrics); + groupSize = CapGroupSize(groupSize, maxGroupSize); + ApplyGroupLayout(groupSize); + + int lo = 1; + int hi = AvailableGridHeight(maxHeight, maxWidth, groupSize, metrics); + int bestGrid = Math.Min(hi, metrics.MinGridHeight); + Size bestSize = MeasureContentSize(ref groupSize, maxWidth, maxHeight, metrics); + + while (lo <= hi) { + int mid = lo + (hi - lo + 1) / 2; + FitGridAndWidth(maxWidth, mid, layoutWidth, metrics); + int nextGroup = CapGroupSize(metrics.GroupSizeForCell(iconGrid.TargetCellSize), maxGroupSize); + groupSize = nextGroup; + ApplyGroupLayout(groupSize); + layoutWidth = ResolvePanelContentWidth(maxWidth, metrics); + Size candidate = MeasureContentSize(ref groupSize, maxWidth, maxHeight, metrics); + if (candidate.Height <= maxHeight) { + bestGrid = mid; + bestSize = candidate; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + FitGridAndWidth(maxWidth, bestGrid, layoutWidth, metrics); + groupSize = CapGroupSize(metrics.GroupSizeForCell(iconGrid.TargetCellSize), maxGroupSize); + ApplyGroupLayout(groupSize); + layoutWidth = ResolvePanelContentWidth(maxWidth, metrics); + bestSize = MeasureContentSize(ref groupSize, maxWidth, maxHeight, metrics); + + for (int trim = 0; trim < 32 && bestSize.Height > maxHeight; trim++) { + int chromeHeight = MeasureLaidOutChromeHeight(maxWidth, groupSize, maxHeight, metrics); + if (iconGrid.Height > 1) { + layoutWidth = ResolvePanelContentWidth(maxWidth, metrics); + int gridHeight = Math.Max(1, maxHeight - chromeHeight - trim); + FitGridAndWidth(maxWidth, gridHeight, layoutWidth, metrics); + groupSize = CapGroupSize(metrics.GroupSizeForCell(iconGrid.TargetCellSize), maxGroupSize); + } else if (groupSize > metrics.MinGroup) { + maxGroupSize = Math.Max(metrics.MinGroup, maxGroupSize - 1); + groupSize = maxGroupSize; + ApplyGroupLayout(groupSize); + } else { + break; + } + Size next = MeasureContentSize(ref groupSize, maxWidth, maxHeight, metrics); + if (next.Height >= bestSize.Height) + break; + bestSize = next; + } + + if (bestSize.Height > maxHeight) { + int chrome = MeasureLaidOutChromeHeight(maxWidth, groupSize, maxHeight, metrics); + FitGridAndWidth(maxWidth, Math.Max(1, maxHeight - chrome), iconGrid.Width, metrics); + groupSize = CapGroupSize(metrics.GroupSizeForCell(iconGrid.TargetCellSize), ResolveMaxGroupSizeForHeight(maxHeight, maxWidth, metrics)); + ApplyGroupLayout(groupSize); + bestSize = MeasureContentSize(ref groupSize, maxWidth, maxHeight, metrics); + } + + return bestSize; + } + + private void UnwrapViewportScrollHost() { + Panel? scrollHost = Controls.Find(EditPanelViewportLayout.ScrollHostName, false).OfType().FirstOrDefault(); + if (scrollHost == null) return; - int headerMin = MeasureHeaderIntrinsicMinWidth(); - if (headerMin <= iconGrid.Width || headerMin > maxWidth) - return; - int gridHeight = AvailableGridHeight(maxHeight, headerMin, groupSize, metrics); - int outer = FitGridAndWidth(maxWidth, gridHeight, headerMin, metrics); - if (outer >= headerMin) - groupSize = metrics.GroupSizeForCell(iconGrid.TargetCellSize); + scrollHost.Controls.Remove(contentStack); + Controls.Remove(scrollHost); + contentStack.Dock = DockStyle.None; + contentStack.Location = Point.Empty; + contentStack.Margin = Padding.Empty; + Controls.Add(contentStack); + contentStack.BringToFront(); + scrollHost.Dispose(); + } + + private int MeasureChromeUsedWidth() { + int width = 0; + FlowLayoutPanel[] rows = [headerStack, groupsPanel, nodeOptionsRowA, nodeOptionsRowB]; + foreach (FlowLayoutPanel row in rows) { + if (!row.Visible) + continue; + row.PerformLayout(); + width = Math.Max(width, row.GetPreferredSize(Size.Empty).Width); + } + return width; + } + + /// Single sizing pipeline: fit content to the viewport and return the measured stack size. + private Size LayoutContentForViewport( + int maxWidth, + int maxHeight, + ref int groupSize, + in ScaledChooserMetrics metrics) { + UnwrapViewportScrollHost(); + + Size contentSize = FitPanelHeightToViewer(maxHeight, maxWidth, ref groupSize, metrics); + CommitContentStackLayout(ref contentSize, ref groupSize, maxWidth, maxHeight, metrics); + + if (contentSize.Height > maxHeight) { + contentSize = FitPanelHeightToViewer(maxHeight, maxWidth, ref groupSize, metrics); + CommitContentStackLayout(ref contentSize, ref groupSize, maxWidth, maxHeight, metrics); + } + + FinalizePanelChromeLayout(ref contentSize, ref groupSize, maxWidth, maxHeight, metrics); + ShrinkPanelToFitViewport(ref contentSize, maxHeight, maxWidth, ref groupSize, metrics); + AlignPanelWidthToChrome(ref contentSize, maxHeight, maxWidth, ref groupSize, metrics); + if (contentSize.Height > maxHeight) + ShrinkPanelToFitViewport(ref contentSize, maxHeight, maxWidth, ref groupSize, metrics); + + return MeasureContentSizeFromLayout(ref groupSize, maxWidth, maxHeight, metrics); + } + + private void ApplyContentStackSize(Size contentSize) { + contentStack.Size = contentSize; + Size = contentSize; + MaximumSize = contentSize; + } + + private void ApplyPanelDimensions(Size contentSize, int minWidth, int minHeight, int maxHeight) { + int cappedMinWidth = Math.Min(minWidth, contentSize.Width); + int cappedMinHeight = Math.Min(minHeight, Math.Min(contentSize.Height, maxHeight)); + AutoSize = false; + MinimumSize = new Size(cappedMinWidth, cappedMinHeight); + ApplyContentStackSize(contentSize); + } + + private Rectangle ComputeChooserScreenBounds(Size panelSize, int margin) { + int viewerWidth = PGViewer!.ClientSize.Width; + int viewerHeight = PGViewer.ClientSize.Height; + Point topLeft = EditPanelScreenLayout.GetChooserTopLeft( + desiredScreenOrigin, panelSize, viewerWidth, viewerHeight, margin); + return EditPanelScreenLayout.ClampRectToViewer( + new Rectangle(topLeft, panelSize), viewerWidth, viewerHeight, margin); + } + + private void ApplyChooserScreenBounds(Rectangle bounds) { + SetBounds(bounds.X, bounds.Y, bounds.Width, bounds.Height, BoundsSpecified.All); + } + + /// WinForms can report taller children after PerformLayout than the pre-layout sum. + private void ReconcileLayoutAfterPerformLayout( + int minWidth, + int minHeight, + int maxWidth, + int maxHeight, + int margin, + ref int groupSize, + in ScaledChooserMetrics metrics) { + int actualHeight = SumLaidOutContentHeight(); + Size contentSize = Size; + if (actualHeight > maxHeight) { + contentSize = LayoutContentForViewport(maxWidth, maxHeight, ref groupSize, metrics); + ApplyPanelDimensions(contentSize, minWidth, minHeight, maxHeight); + } else if (actualHeight > 0 && Math.Abs(actualHeight - Size.Height) > 1) { + contentSize = new Size(Size.Width, actualHeight); + ApplyPanelDimensions(contentSize, minWidth, minHeight, maxHeight); + SyncChromeRowWidths(ResolvePanelContentWidth(maxWidth, metrics), groupSize, maxHeight, metrics); + } + + Rectangle bounds = ComputeChooserScreenBounds(contentSize, margin); + if (Bounds != bounds) + ApplyChooserScreenBounds(bounds); + + int viewerWidth = PGViewer!.ClientSize.Width; + int viewerHeight = PGViewer.ClientSize.Height; + if (!EditPanelScreenLayout.FitsViewer(Bounds, viewerWidth, viewerHeight, margin)) + ApplyChooserScreenBounds(ComputeChooserScreenBounds(Size, margin)); + + SyncFooterButtonsToGridCell(); } private void ApplyViewerBounds() { if (applyingViewerBounds || PGViewer == null) return; + if (!PGViewer.IsHandleCreated) + PGViewer.CreateControl(); applyingViewerBounds = true; + const int margin = EditPanelScreenLayout.DefaultMargin; + int maxHeight = Math.Max(1, PGViewer.ClientSize.Height - margin * 2); + int maxWidth = Math.Max(1, PGViewer.ClientSize.Width - margin * 2); + ScaledChooserMetrics metrics = GetScaledMetrics(); + int groupSize = metrics.DesignGroup; + int minWidth = 0; + int minHeight = 0; + SuspendLayout(); + contentStack.SuspendLayout(); try { - const int margin = EditPanelScreenLayout.DefaultMargin; - int maxHeight = Math.Max(1, PGViewer.ClientSize.Height - margin * 2); - int maxWidth = Math.Max(1, PGViewer.ClientSize.Width - margin * 2); - ScaledChooserMetrics metrics = GetScaledMetrics(); int width = Math.Min(metrics.DesignWidth, maxWidth); - int groupSize = metrics.DesignGroup; for (int pass = 0; pass < 8; pass++) { int prevGroup = groupSize; @@ -210,48 +670,101 @@ private void ApplyViewerBounds() { break; } - int minWidth = ComputeMinimumWidth(maxWidth, metrics); - WidenGridToMinimumWidthIfPossible(maxHeight, maxWidth, minWidth, ref groupSize, metrics); - ExpandGridForHeaderIfNeeded(maxHeight, maxWidth, ref groupSize, metrics); - - Size contentSize = MeasureContentSize(groupSize); - int minHeight = MeasureMinimumPanelHeight(contentSize.Width, groupSize, metrics) + metrics.MinGridHeight; + minWidth = ComputeMinimumWidth(maxWidth, metrics); + WidenGridToMinimumWidthIfPossible(maxHeight, maxWidth, ref groupSize, metrics); - ApplyTightPanelBounds(contentSize, minWidth, minHeight); + groupSize = Math.Min(groupSize, ResolveMaxGroupSizeForHeight(maxHeight, maxWidth, metrics)); + ApplyGroupLayout(groupSize); - Rectangle bounds = EditPanelScreenLayout.ClampRectToViewer( - new Rectangle(Location, Size), PGViewer.ClientSize.Width, PGViewer.ClientSize.Height, margin); - Location = bounds.Location; + minHeight = MeasureMinimumPanelHeight(ResolvePanelContentWidth(maxWidth, metrics), groupSize, metrics) + metrics.MinGridHeight; + Size contentSize = LayoutContentForViewport(maxWidth, maxHeight, ref groupSize, metrics); + ApplyPanelDimensions(contentSize, minWidth, minHeight, maxHeight); + ApplyChooserScreenBounds(ComputeChooserScreenBounds(contentSize, margin)); } finally { + contentStack.ResumeLayout(performLayout: false); + ResumeLayout(performLayout: false); applyingViewerBounds = false; } + + PerformLayout(); + SyncFooterButtonsToGridCell(); + ReconcileLayoutAfterPerformLayout(minWidth, minHeight, maxWidth, maxHeight, margin, ref groupSize, metrics); } private int ComputeMinimumWidth(int maxWidth, in ScaledChooserMetrics metrics) { int minGridOuter = ChooserLayout.DesignMinVisibleRows * metrics.MinCell + GetScrollbarWidth(); int headerAtMinGrid = MeasureHeaderIntrinsicMinWidth(); - return Math.Min(maxWidth, Math.Min(metrics.DesignWidth, Math.Max(minGridOuter, headerAtMinGrid))); + int footerHeight = ResolveFooterButtonHeight(metrics); + float footerFont = ResolveFooterButtonFontSize(metrics); + int footerMin = MeasureFooterButtonsNaturalWidth(footerHeight, footerFont); + return Math.Min(maxWidth, Math.Min(metrics.DesignWidth, Math.Max(minGridOuter, Math.Max(headerAtMinGrid, footerMin)))); } - private void ApplyTightPanelBounds(Size contentSize, int minWidth, int minHeight) { - contentStack.Size = contentSize; - contentStack.PerformLayout(); + private void AlignPanelWidthToChrome(ref Size contentSize, int maxHeight, int maxWidth, ref int groupSize, in ScaledChooserMetrics metrics) { + SyncChromeRowWidths(ResolvePanelContentWidth(maxWidth, metrics), groupSize, maxHeight, metrics); + contentSize = MeasureContentSizeFromLayout(ref groupSize, maxWidth, maxHeight, metrics); + ApplyContentStackSize(contentSize); + } - contentSize = MeasureContentSizeFromLayout(); - int cappedMinWidth = Math.Min(minWidth, contentSize.Width); - int cappedMinHeight = Math.Min(minHeight, contentSize.Height); + /// Iteratively shrink chrome + grid until measured height fits the viewer (short windows). + private void ShrinkPanelToFitViewport(ref Size contentSize, int maxHeight, int maxWidth, ref int groupSize, in ScaledChooserMetrics metrics) { + int previousHeight = int.MaxValue; + for (int attempt = 0; attempt < 32 && contentSize.Height > maxHeight; attempt++) { + int rowWidth = ResolvePanelContentWidth(maxWidth, metrics); + int maxGroup = ResolveMaxGroupSizeForHeight(maxHeight, maxWidth, metrics); + groupSize = Math.Max(metrics.MinGroup, maxGroup - attempt / 4); + ApplyGroupLayout(groupSize); + + int footerButtonHeight = ResolveFooterButtonHeight(metrics); + float footerFont = ResolveFooterButtonFontSize(metrics); + int chrome = MeasureHeaderFooterHeight(rowWidth) + MeasureFooterChromeHeight(rowWidth, footerButtonHeight, footerFont); + if (groupsPanel.Visible) { + int groupsCap = Math.Max(metrics.MinGroup, maxHeight - chrome - metrics.MinGridHeight - attempt); + LayoutGroupsPanelSize(rowWidth, groupsCap); + } - contentStack.Size = contentSize; - MinimumSize = new Size(cappedMinWidth, cappedMinHeight); - Size = contentSize; - MaximumSize = contentSize; + chrome = MeasureLaidOutChromeHeight(maxWidth, groupSize, maxHeight, metrics); + int gridBudget = Math.Max(1, maxHeight - chrome); + FitGridAndWidth(maxWidth, gridBudget, iconGrid.Width, metrics); + groupSize = CapGroupSize(metrics.GroupSizeForCell(iconGrid.TargetCellSize), maxGroup); + ApplyGroupLayout(groupSize); + + SyncChromeRowWidths(rowWidth, groupSize, maxHeight, metrics); + contentSize = MeasureContentSizeFromLayout(ref groupSize, maxWidth, maxHeight, metrics); + + if (contentSize.Height >= previousHeight) + break; + previousHeight = contentSize.Height; + } + } + + /// Re-apply row heights after assigning panel Size (parent layout resets AutoSize chrome). + private void FinalizePanelChromeLayout(ref Size contentSize, ref int groupSize, int maxWidth, int maxHeight, in ScaledChooserMetrics metrics) { + SyncChromeRowWidths(ResolvePanelContentWidth(maxWidth, metrics), groupSize, maxHeight, metrics); + contentSize = MeasureContentSizeFromLayout(ref groupSize, maxWidth, maxHeight, metrics); + int rowWidth = Math.Max(contentSize.Width, ResolvePanelContentWidth(maxWidth, metrics)); + if (rowWidth > contentSize.Width) { + SyncChromeRowWidths(rowWidth, groupSize, maxHeight, metrics); + contentSize = new Size(rowWidth, contentSize.Height); + } + ApplyContentStackSize(contentSize); } - private Size MeasureContentSizeFromLayout() { - int width = MeasureContentWidth(); - SyncChromeRowWidths(width); - int height = SumVisibleHeights(headerStack, groupsPanel, iconGrid, nodeOptionsRowA, nodeOptionsRowB); - return new Size(width, height); + /// Apply explicit row heights; contentStack.PerformLayout can reset AutoSize children to design metrics. + private void CommitContentStackLayout( + ref Size contentSize, ref int groupSize, int maxWidth, int maxHeight, in ScaledChooserMetrics metrics) { + SyncChromeRowWidths(ResolvePanelContentWidth(maxWidth, metrics), groupSize, maxHeight, metrics); + ApplyContentStackSize(contentSize); + contentSize = MeasureContentSizeFromLayout(ref groupSize, maxWidth, maxHeight, metrics); + } + + private int SumLaidOutContentHeight() => + SumVisibleHeights(headerStack, groupsPanel, iconGridBand, nodeOptionsRowA, nodeOptionsRowB); + + private Size MeasureContentSizeFromLayout(ref int groupSize, int maxWidth, int maxHeight, in ScaledChooserMetrics metrics) { + int width = ResolvePanelContentWidth(maxWidth, metrics); + SyncChromeRowWidths(width, groupSize, maxHeight, metrics); + return new Size(width, SumLaidOutContentHeight()); } protected override void OnCreateControl() { @@ -274,9 +787,11 @@ private void ApplyDesignTimeLayout() { EnsureDesignTimeGroupPreview(metrics.DesignGroup); - Size contentSize = MeasureContentSize(metrics.DesignGroup); - contentStack.Size = contentSize; - Size = contentSize; + int designGroup = metrics.DesignGroup; + Size contentSize = MeasureContentSize(ref designGroup, metrics.DesignWidth, int.MaxValue, metrics); + ApplyFooterChromeLayout(contentSize.Width, metrics); + contentSize = MeasureContentSizeFromLayout(ref designGroup, metrics.DesignWidth, int.MaxValue, metrics); + ApplyContentStackSize(contentSize); MinimumSize = contentSize; } diff --git a/Foreman/Controls/IRChooserPanel.cs b/Foreman/Controls/IRChooserPanel.cs index 2e3eeac..ab2fab3 100644 --- a/Foreman/Controls/IRChooserPanel.cs +++ b/Foreman/Controls/IRChooserPanel.cs @@ -27,6 +27,9 @@ public enum ChooserPanelCloseReason { internal ChooserPanelCloseReason PanelCloseReason { get; set; } private bool isClosing; private EventHandler? viewerResizeHandler; + private System.Windows.Forms.Timer? viewerBoundsDebounceTimer; + private const int ViewerBoundsDebounceMilliseconds = 200; + private readonly Point desiredScreenOrigin; private static readonly Color SelectedGroupButtonBGColor = Color.SandyBrown; protected static readonly Color IRButtonDefaultColor = Color.FromArgb(255, 70, 70, 70); @@ -91,7 +94,7 @@ public IRChooserPanel(ProductionGraphViewer parent, Point originPoint) { IgnoreAssemblerCheckBox.Checked = Properties.Settings.Default.IgnoreAssemblerStatus; RecipeNameOnlyFilterCheckBox.Checked = Properties.Settings.Default.RecipeNameOnlyFilter; - this.Location = originPoint; + desiredScreenOrigin = originPoint; } protected override void OnDpiChangedAfterParent(EventArgs e) { @@ -113,23 +116,70 @@ protected override void OnDpiChangedAfterParent(EventArgs e) { ShowHiddenCheckBox.CheckedChanged += new EventHandler(FilterCheckBoxCheckedChanged); IgnoreAssemblerCheckBox.CheckedChanged += new EventHandler(FilterCheckBoxCheckedChanged); + if (!IsHandleCreated) + CreateControl(); + + // Lay out at final size/position before parenting onto the graph viewer to avoid flashing + // designer-default child positions during the expensive bounds pass. Visible = true; - PGViewer.Controls.Add(this); - viewerResizeHandler ??= (_, _) => ApplyViewerBounds(); + RefreshViewerBounds(); + + PGViewer.SuspendLayout(); + try { + PGViewer.Controls.Add(this); + } finally { + PGViewer.ResumeLayout(false); + } + + viewerResizeHandler ??= QueueViewerBoundsRefresh; PGViewer.Resize += viewerResizeHandler; BringToFront(); - ApplyViewerBounds(); - PGViewer.PerformLayout(); FilterTextBox.Focus(); } + private void QueueViewerBoundsRefresh(object? sender, EventArgs e) { + if (applyingViewerBounds || IsDisposed || !Visible || PGViewer == null) + return; + if (viewerBoundsDebounceTimer == null) { + viewerBoundsDebounceTimer = new System.Windows.Forms.Timer { Interval = ViewerBoundsDebounceMilliseconds }; + viewerBoundsDebounceTimer.Tick += (_, _) => { + viewerBoundsDebounceTimer!.Stop(); + RefreshViewerBounds(); + }; + } + viewerBoundsDebounceTimer.Stop(); + viewerBoundsDebounceTimer.Start(); + } + + private void RefreshViewerBounds() { + if (refreshingViewerBounds || IsDisposed || !Visible || PGViewer == null) + return; + refreshingViewerBounds = true; + try { + ApplyViewerBounds(); + } finally { + refreshingViewerBounds = false; + } + } + private void DetachViewerResizeHandler() { - if (PGViewer != null && viewerResizeHandler != null) { + DisposeViewerBoundsDebounceTimer(); + if (PGViewer == null) + return; + if (viewerResizeHandler != null) { PGViewer.Resize -= viewerResizeHandler; viewerResizeHandler = null; } } + private void DisposeViewerBoundsDebounceTimer() { + if (viewerBoundsDebounceTimer == null) + return; + viewerBoundsDebounceTimer.Stop(); + viewerBoundsDebounceTimer.Dispose(); + viewerBoundsDebounceTimer = null; + } + //-----------------------------------------------------------------------------------------------------Button initialization & update private void InitializeButtons() { @@ -140,6 +190,8 @@ private void InitializeButtons() { groupsPanel.Controls.Clear(); GroupButtons.Clear(); GroupButtonLinks.Clear(); + groupsPanel.AutoSize = false; + groupsPanel.WrapContents = true; int groupButtonSize = ChooserLayout.Scale(this, ChooserLayout.DesignGroupIconPixels); for (int i = 0; i < SortedGroups.Count; i++) { @@ -371,7 +423,10 @@ private void IRChooserPanel_Leave(object? sender, EventArgs e) { } } - protected virtual void IRChooserPanelDisposed(object? sender, EventArgs e) { } + protected virtual void IRChooserPanelDisposed(object? sender, EventArgs e) { + DisposeViewerBoundsDebounceTimer(); + DisposeScaledFooterButtonFont(); + } } diff --git a/Foreman/Forms/MainForm.Designer.cs b/Foreman/Forms/MainForm.Designer.cs index 03c0a54..17c4265 100644 --- a/Foreman/Forms/MainForm.Designer.cs +++ b/Foreman/Forms/MainForm.Designer.cs @@ -89,8 +89,7 @@ private void InitializeComponent() // this.GraphViewer.AllowDrop = true; this.GraphViewer.ArrowsOnLinks = false; - this.GraphViewer.AutoSize = true; - this.GraphViewer.AutoSizeMode = System.Windows.Forms.AutoSizeMode.GrowAndShrink; + this.GraphViewer.AutoSize = false; this.GraphViewer.BackColor = System.Drawing.Color.White; this.GraphViewer.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; this.GraphViewer.Dock = System.Windows.Forms.DockStyle.Fill; diff --git a/Foreman/ProductionGraphView/EditPanelScreenLayout.cs b/Foreman/ProductionGraphView/EditPanelScreenLayout.cs index a58cb3f..76512c3 100644 --- a/Foreman/ProductionGraphView/EditPanelScreenLayout.cs +++ b/Foreman/ProductionGraphView/EditPanelScreenLayout.cs @@ -41,6 +41,14 @@ public static void ShiftControlsToFit(Rectangle desiredUnion, int viewerWidth, i foreach (Control panel in panels) panel.Location = Point.Add(panel.Location, (Size)delta); } + + /// Places a floating chooser near (client coords), then clamps to the viewer. + public static Point GetChooserTopLeft(Point anchor, Size panelSize, int viewerWidth, int viewerHeight, int margin = DefaultMargin) { + const int anchorInsetX = 24; + const int anchorInsetY = 16; + var desired = new Rectangle(anchor.X - anchorInsetX, anchor.Y - anchorInsetY, panelSize.Width, panelSize.Height); + return ClampRectToViewer(desired, viewerWidth, viewerHeight, margin).Location; + } } } diff --git a/Foreman/ProductionGraphView/Elements/DraggedLinkElement.cs b/Foreman/ProductionGraphView/Elements/DraggedLinkElement.cs index b1325cf..81a0d24 100644 --- a/Foreman/ProductionGraphView/Elements/DraggedLinkElement.cs +++ b/Foreman/ProductionGraphView/Elements/DraggedLinkElement.cs @@ -123,8 +123,7 @@ private void EndDrag(Point graphPoint) { graphViewer.AddPassthroughNodesFromSelection(StartConnectionType, (Size)Point.Subtract(EndpointLocation, (Size)originElement.Location)); } else //at least one null -> this is an 'add new recipe' operation { - var screenPoint = new Point(graphViewer.GraphToScreen(graphPoint).X - 150, 15); - screenPoint.X = Math.Max(15, Math.Min(graphViewer.Width - 650, screenPoint.X)); //want to position the recipe selector such that it is well visible. + Point screenPoint = graphViewer.GraphToScreen(graphPoint); if (StartConnectionType == LinkType.Input && SupplierElement == null) graphViewer.AddNewNode(screenPoint, Item, EndpointLocation, NewNodeType.Supplier, ConsumerElement, true); diff --git a/Foreman/ProductionGraphView/ProductionGraphViewer.cs b/Foreman/ProductionGraphView/ProductionGraphViewer.cs index 5284f2f..d20fece 100644 --- a/Foreman/ProductionGraphView/ProductionGraphViewer.cs +++ b/Foreman/ProductionGraphView/ProductionGraphViewer.cs @@ -506,7 +506,8 @@ public void SetSelectedPassthroughNodesSimpleDraw(bool simpleDraw) { } private void PlaceFloatingPanels(Rectangle desiredBounds, params Control[] panels) => - EditPanelScreenLayout.ShiftControlsToFit(desiredBounds, Width, Height, EditPanelScreenLayout.DefaultMargin, panels); + EditPanelScreenLayout.ShiftControlsToFit( + desiredBounds, ClientSize.Width, ClientSize.Height, EditPanelScreenLayout.DefaultMargin, panels); public void EditNode(BaseNodeElement bNodeElement) { if (bNodeElement is RecipeNodeElement rNodeElement) { @@ -902,8 +903,7 @@ private void ProductionGraphViewer_MouseUp(object? sender, MouseEventArgs e) { viewBeingDragged = false; else if (currentDragOperation == DragOperation.None && element == null) //right click on an empty space -> show add item/recipe menu { - var screenPoint = new Point(e.Location.X - 150, 15); - screenPoint.X = Math.Max(15, Math.Min(Width - 650, screenPoint.X)); //want to position the recipe selector such that it is well visible. + Point screenPoint = e.Location; rightClickMenu.Items.Clear(); rightClickMenu.Items.Add(new ToolStripMenuItem("Add Item", null, diff --git a/ForemanTest/ChooserLayoutTests.cs b/ForemanTest/ChooserLayoutTests.cs index af405d3..8097a7e 100644 --- a/ForemanTest/ChooserLayoutTests.cs +++ b/ForemanTest/ChooserLayoutTests.cs @@ -1,5 +1,6 @@ using Foreman; using Foreman.Controls; +using Foreman.ProductionGraphView; using Foreman.Serialization; using ForemanTest.Graph; using ForemanTest.support; @@ -33,6 +34,41 @@ public void GroupIconSizeForCell_DoesNotExceedDesignGroup() { Assert.AreEqual(64, ChooserLayout.GroupIconSizeForCell(100, 64, 24)); } + [TestMethod] + public void FooterButtonHeightForCell_MatchesDesignRatioAtFullCell() { + Assert.AreEqual(38, ChooserLayout.FooterButtonHeightForCell(40, 38, 22)); + } + + [TestMethod] + public void FooterButtonHeightForCell_ScalesDownWithCell() { + Assert.AreEqual(28, ChooserLayout.FooterButtonHeightForCell(30, 38, 22)); + } + + [TestMethod] + public void FooterButtonHeightForCell_ClampsToMinimum() { + Assert.AreEqual(22, ChooserLayout.FooterButtonHeightForCell(10, 38, 22)); + } + + [TestMethod] + public void FooterButtonHeightForCell_DoesNotExceedDesignHeight() { + Assert.AreEqual(38, ChooserLayout.FooterButtonHeightForCell(100, 38, 22)); + } + + [TestMethod] + public void FooterButtonFontSizeForCell_MatchesDesignRatioAtFullCell() { + Assert.AreEqual(8.25f, ChooserLayout.FooterButtonFontSizeForCell(40, 40, 8.25f, 6f), 0.01f); + } + + [TestMethod] + public void FooterButtonFontSizeForCell_ScalesDownWithCell() { + Assert.AreEqual(6.1875f, ChooserLayout.FooterButtonFontSizeForCell(30, 40, 8.25f, 6f), 0.01f); + } + + [TestMethod] + public void FooterButtonFontSizeForCell_ClampsToMinimum() { + Assert.AreEqual(6f, ChooserLayout.FooterButtonFontSizeForCell(10, 40, 8.25f, 6f), 0.01f); + } + [TestMethod] public void ChooserIconGrid_ScrollBarWidth_MatchesSystemVerticalScrollbarWidth() => StaTest.Run(ChooserIconGrid_ScrollBarWidth_MatchesSystemVerticalScrollbarWidth_Impl); @@ -117,6 +153,38 @@ private static ChooserIconGrid GetIconGrid(IRChooserPanel chooser) { return (ChooserIconGrid)field.GetValue(chooser)!; } + [TestMethod] + public void ItemChooser_HeightFitsShortViewer() => + StaTest.Run(ItemChooser_HeightFitsShortViewer_Impl); + + private static void ItemChooser_HeightFitsShortViewer_Impl() { + var ctx = GraphSessionTestHelper.CreateContext(); + TestDataCacheHelper.SetPresetName(ctx.Cache, "test-preset"); + const int margin = EditPanelScreenLayout.DefaultMargin; + (int Width, int Height)[] viewerSizes = [(1280, 720), (1024, 600), (900, 550), (800, 500)]; + + foreach ((int viewerWidth, int viewerHeight) in viewerSizes) { + using var viewer = new ProductionGraphViewer { + DCache = ctx.Cache, + Size = new Size(viewerWidth, viewerHeight), + }; + viewer.ApplySaveUi(new GraphViewerUiSaveData { ViewOffset = Point.Empty, ViewScale = 1f }, ctx.Cache, setEnablesFromJson: false); + viewer.PerformLayout(); + viewer.AddItem(new Point(20, 20), new Point(200, 150)); + viewer.PerformLayout(); + ItemChooserPanel? chooser = viewer.Controls.OfType().FirstOrDefault(); + Assert.IsNotNull(chooser); + + int maxPanelHeight = viewer.ClientSize.Height - margin * 2; + Assert.IsLessThanOrEqualTo(maxPanelHeight, chooser.Height, + $"At viewer {viewerWidth}x{viewerHeight}, chooser height {chooser.Height} should fit within {maxPanelHeight}px."); + Assert.IsTrue(EditPanelScreenLayout.FitsViewer(chooser.Bounds, viewer.ClientSize.Width, viewer.ClientSize.Height, margin), + $"Chooser at {chooser.Bounds} should be fully visible in viewer client area {viewer.ClientSize}."); + Assert.IsNull(chooser.Controls.Find(EditPanelViewportLayout.ScrollHostName, false).FirstOrDefault(), + "Chooser should shrink rather than add a panel scrollbar."); + } + } + [TestMethod] public void ItemChooser_NoRightDeadSpaceWhenViewerShrinks() => StaTest.Run(ItemChooser_NoRightDeadSpaceWhenViewerShrinks_Impl); @@ -138,37 +206,42 @@ private static void ItemChooser_NoRightDeadSpaceWhenViewerShrinks_Impl() { foreach ((int viewerWidth, int viewerHeight) in viewerSizes) { viewer.Size = new Size(viewerWidth, viewerHeight); viewer.PerformLayout(); - chooser.PerformLayout(); + MethodInfo? refreshBounds = typeof(IRChooserPanel).GetMethod("RefreshViewerBounds", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(refreshBounds); + refreshBounds.Invoke(chooser, null); FlowLayoutPanel contentStack = GetContentStack(chooser); ChooserIconGrid iconGrid = GetIconGrid(chooser); + Panel iconGridBand = GetIconGridBand(chooser); int deadSpace = MeasureChooserRightDeadSpace(chooser); - int gapBesideGrid = MeasureGapRightOfIconGrid(contentStack, iconGrid); + int gapBesideGrid = MeasureGapBesideIconGrid(iconGridBand, iconGrid); + int gridOffsetInBand = iconGrid.Left; Assert.IsLessThanOrEqualTo(2, deadSpace, - $"At viewer {viewerWidth}x{viewerHeight}, chooser had {deadSpace}px black bar past content " + + $"At viewer {viewerWidth}x{viewerHeight}, chooser had {deadSpace}px unused space past content " + $"(panel {chooser.Width}, stack {contentStack.Width}, grid {iconGrid.Width})."); - Assert.IsLessThanOrEqualTo(2, gapBesideGrid, - $"At viewer {viewerWidth}x{viewerHeight}, {gapBesideGrid}px black bar sat to the right of the icon grid " + - $"(stack {contentStack.Width}, grid right {iconGrid.Right}, header/min width mismatch)."); + Assert.IsLessThanOrEqualTo(2, Math.Abs(gapBesideGrid - gridOffsetInBand * 2), + $"At viewer {viewerWidth}x{viewerHeight}, icon grid should be centered in its dim-gray band " + + $"(band {iconGridBand.Width}px, grid {iconGrid.Width}px, left offset {gridOffsetInBand}px)."); Assert.IsLessThanOrEqualTo(2, chooser.Width - contentStack.Width, "Panel width should match the content stack."); - Assert.IsLessThanOrEqualTo(2, contentStack.Width - iconGrid.Width, - "Content stack width should match the icon grid; extra width becomes a black strip beside the cells."); + Assert.AreEqual(Color.DimGray, iconGridBand.BackColor, + "Space beside the square grid should use the dim-gray band, not the panel black."); Assert.IsLessThanOrEqualTo(chooser.Width, chooser.MinimumSize.Width, $"MinimumSize.Width ({chooser.MinimumSize.Width}) must not exceed actual width ({chooser.Width})."); } } - private static int MeasureGapRightOfIconGrid(FlowLayoutPanel contentStack, ChooserIconGrid iconGrid) { - if (!iconGrid.Visible) + private static int MeasureGapBesideIconGrid(Panel iconGridBand, ChooserIconGrid iconGrid) { + if (!iconGrid.Visible || !iconGridBand.Visible) return 0; - int widestChromeRight = contentStack.Controls.Cast() - .Where(c => c.Visible && c != iconGrid) - .Select(c => c.Right) - .DefaultIfEmpty(0) - .Max(); - return Math.Max(0, Math.Max(widestChromeRight, contentStack.Width) - iconGrid.Right); + return Math.Max(0, iconGridBand.Width - iconGrid.Width); + } + + private static Panel GetIconGridBand(IRChooserPanel chooser) { + FieldInfo? field = typeof(IRChooserPanel).GetField("iconGridBand", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field, "IRChooserPanel.iconGridBand field should exist."); + return (Panel)field.GetValue(chooser)!; } private static int MeasureChooserRightDeadSpace(IRChooserPanel chooser) { @@ -210,6 +283,26 @@ private static void ChooserIconGrid_ApplyLayout_SizesGridToCellCount_Impl() { Assert.IsGreaterThanOrEqualTo(grid.Width - scrollbar, grid.ScrollBar.Left); } + [TestMethod] + public void ChooserIconGrid_ApplyLayout_RoundsUpToMinOuterWidth() => + StaTest.Run(ChooserIconGrid_ApplyLayout_RoundsUpToMinOuterWidth_Impl); + + private static void ChooserIconGrid_ApplyLayout_RoundsUpToMinOuterWidth_Impl() { + using var grid = new ChooserIconGrid(); + int scrollbar = SystemInformation.VerticalScrollBarWidth; + int outerWidth = grid.ApplyLayout( + availableGridHeight: ChooserIconGrid.VisibleRowCount * 40, + maxLayoutWidth: 270, + designCellSize: 40, + minCellSize: 18, + scrollbarWidth: scrollbar, + minOuterWidth: 251); + + Assert.IsTrue(outerWidth >= 251, + $"Grid outer width {outerWidth} should meet chrome minimums that do not land on a cell boundary."); + Assert.IsTrue(outerWidth <= 270); + } + [TestMethod] public void ChooserIconGrid_ApplyLayout_ShrinksWhenHeightLimited() => StaTest.Run(ChooserIconGrid_ApplyLayout_ShrinksWhenHeightLimited_Impl); diff --git a/ForemanTest/ProductionGraphViewerEditTests.cs b/ForemanTest/ProductionGraphViewerEditTests.cs index 2098824..fe73938 100644 --- a/ForemanTest/ProductionGraphViewerEditTests.cs +++ b/ForemanTest/ProductionGraphViewerEditTests.cs @@ -1,5 +1,7 @@ using Foreman; +using Foreman.Controls; using Foreman.DataCaching.DataTypes; +using Foreman.DataCaching.Loading; using Foreman.Graph; using Foreman.Models; using Foreman.ProductionGraphView; @@ -46,6 +48,30 @@ public void ItemChooser_ClosesOnGraphClick_AndSelectsWithoutException() => public void ItemChooser_SizeMatchesContentAfterShow() => StaTest.Run(ItemChooser_SizeMatchesContentAfterShow_Impl); + [TestMethod] + public void ItemChooser_StaysFullyVisibleWhenOpenedNearViewerEdge() => + StaTest.Run(ItemChooser_StaysFullyVisibleWhenOpenedNearViewerEdge_Impl); + + [TestMethod] + public void RecipeChooser_FooterButtonsFitPanelWidth() => + StaTest.Run(RecipeChooser_FooterButtonsFitPanelWidth_Impl); + + [TestMethod] + public void RecipeChooser_FooterButtonsScaleWithViewerSize() => + StaTest.Run(RecipeChooser_FooterButtonsScaleWithViewerSize_Impl); + + [TestMethod] + public void RecipeChooser_HeaderAndFooterControlsFitPanelWidth() => + StaTest.Run(RecipeChooser_HeaderAndFooterControlsFitPanelWidth_Impl); + + [TestMethod] + public void RecipeChooser_RecipeOnlyCheckboxFitsPanelWidthOnShortViewer() => + StaTest.Run(RecipeChooser_RecipeOnlyCheckboxFitsPanelWidthOnShortViewer_Impl); + + [TestMethod] + public void RecipeChooser_ManyGroupsFitsViewportWithFooterVisible() => + StaTest.Run(RecipeChooser_ManyGroupsFitsViewportWithFooterVisible_Impl); + [TestMethod] public void EditRecipePanel_HeightFitsViewerAndScrollsWhenContentIsTaller() => StaTest.Run(EditRecipePanel_HeightFitsViewerAndScrollsWhenContentIsTaller_Impl); @@ -222,19 +248,275 @@ private static void EditFlowPanel_HeightFitsShortViewer_Impl() { } } + private static void ItemChooser_StaysFullyVisibleWhenOpenedNearViewerEdge_Impl() { + var ctx = GraphSessionTestHelper.CreateContext(); + TestDataCacheHelper.SetPresetName(ctx.Cache, "test-preset"); + using var viewer = CreateViewer(ctx, lockedRecipeEditor: false, viewOffset: new Point(0, 0)); + viewer.Size = new Size(520, 560); + + viewer.AddItem(new Point(460, 500), new Point(200, 150)); + AssertFloatingPanelsOnScreen(viewer); + } + + private static void SeedManyChooserGroups(GraphSessionTestHelper.TestContext ctx, int groupCount) { + DataCacheStore store = TestDataCacheHelper.RequireStore(ctx.Cache); + AssemblerPrototype assembler = TestPrototypeFactory.CreateTestAssembler(ctx.Cache); + for (int i = 0; i < groupCount; i++) { + var group = new GroupPrototype(ctx.Cache, $"§§t:g{i}", $"G{i}", $"{i:D4}"); + var subgroup = new SubgroupPrototype(ctx.Cache, $"§§t:s{i}", "0") { MyGroupInternal = group }; + group.SubgroupsInternal.Add(subgroup); + ItemPrototype item = TestDataCacheHelper.GetOrCreateItem(ctx.Cache, ctx.Subgroup, $"t-item{i}"); + var recipe = new RecipePrototype(ctx.Cache, $"§§t:r{i}", $"R{i}", subgroup, "0"); + TestPrototypeFactory.SetRecipeTime(recipe, 1); + TestPrototypeFactory.LinkRecipeAndAssembler(recipe, assembler); + recipe.InternalOneWayAddIngredient(item, 1); + recipe.InternalOneWayAddProduct(item, 1, 0); + item.ConsumptionRecipesInternal.Add(recipe); + item.ProductionRecipesInternal.Add(recipe); + subgroup.ItemsInternal.Add(item); + subgroup.RecipesInternal.Add(recipe); + store.Groups[group.Name] = group; + store.Subgroups[subgroup.Name] = subgroup; + TestDataCacheHelper.RegisterRecipe(ctx.Cache, recipe); + } + } + + private static void RecipeChooser_ManyGroupsFitsViewportWithFooterVisible_Impl() { + var ctx = GraphSessionTestHelper.CreateContext(); + SeedManyChooserGroups(ctx, 24); + TestDataCacheHelper.SetPresetName(ctx.Cache, "test-preset"); + using var viewer = CreateViewer(ctx, lockedRecipeEditor: false, viewOffset: new Point(0, 0)); + viewer.CreateControl(); + viewer.Size = new Size(900, 380); + viewer.PerformLayout(); + + viewer.AddItem(new Point(20, 20), new Point(200, 150)); + viewer.PerformLayout(); + Application.DoEvents(); + + ItemChooserPanel? itemChooser = viewer.Controls.OfType().FirstOrDefault(); + Assert.IsNotNull(itemChooser); + SelectItemInChooser(itemChooser, ctx.Item("t-item0")); + + RecipeChooserPanel? chooser = viewer.Controls.OfType().FirstOrDefault(); + Assert.IsNotNull(chooser); + MethodInfo? refreshBounds = typeof(IRChooserPanel).GetMethod("RefreshViewerBounds", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(refreshBounds); + refreshBounds.Invoke(chooser, null); + Application.DoEvents(); + + const int margin = EditPanelScreenLayout.DefaultMargin; + int maxPanelHeight = viewer.ClientSize.Height - margin * 2; + Assert.IsTrue(chooser.Height <= maxPanelHeight, + $"Recipe chooser height {chooser.Height} should fit a {viewer.ClientSize.Height}px-tall viewer (max content {maxPanelHeight}px)."); + + Button passThrough = GetChooserButton(chooser, "AddPassthroughButton"); + Assert.IsTrue(passThrough.Visible); + Assert.IsTrue(passThrough.Bottom <= chooser.ClientSize.Height, + $"Pass-Through bottom ({passThrough.Bottom}) should be inside the panel ({chooser.ClientSize.Height}px)."); + + FlowLayoutPanel groups = GetGroupsPanel(chooser); + Assert.IsTrue(groups.Visible, "Recipe chooser should show category groups when the item has matching recipes."); + Assert.IsTrue(groups.Height > 20, + $"With many categories, groups panel height {groups.Height} should show a category strip (or scroll when capped)."); + Assert.IsTrue(groups.Controls.Count >= 20, + "Seeded mod-style groups should populate the category strip."); + + AssertFloatingPanelsOnScreen(viewer); + } + + private static void SelectItemInChooser(ItemChooserPanel chooser, ItemQualityPair item) { + MethodInfo? mouseUp = typeof(ItemChooserPanel).GetMethod("IRButtonMouseUp", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(mouseUp); + using var button = new Button { Tag = item.Item }; + mouseUp.Invoke(chooser, [button, new MouseEventArgs(MouseButtons.Left, 1, 0, 0, 0)]); + } + + private static FlowLayoutPanel GetGroupsPanel(IRChooserPanel chooser) { + FieldInfo? field = typeof(IRChooserPanel).GetField("groupsPanel", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field); + return (FlowLayoutPanel)field.GetValue(chooser)!; + } + + private static void RecipeChooser_HeaderAndFooterControlsFitPanelWidth_Impl() { + var ctx = GraphSessionTestHelper.CreateContext(); + TestDataCacheHelper.SetPresetName(ctx.Cache, "test-preset"); + using var viewer = CreateViewer(ctx, lockedRecipeEditor: false, viewOffset: new Point(0, 0)); + + NodeId supplierId = viewer.Session.Editor.CreateSupplierNode(ctx.Item("iron"), new Point(100, 100)); + Assert.IsTrue(viewer.NodeElementDictionary.TryGetValue(supplierId, out BaseNodeElement? supplier)); + Assert.IsNotNull(supplier); + + viewer.AddNewNode(new Point(10, 10), ctx.Item("iron"), new Point(300, 100), NewNodeType.Consumer, supplier); + RecipeChooserPanel? chooser = viewer.Controls.OfType().FirstOrDefault(); + Assert.IsNotNull(chooser); + + Button passThrough = GetChooserButton(chooser, "AddPassthroughButton"); + CheckBox showHidden = GetChooserCheckBox(chooser, "ShowHiddenCheckBox"); + Assert.IsTrue(passThrough.Visible); + Assert.IsLessThanOrEqualTo(chooser.ClientSize.Width, passThrough.Right, + $"Pass-Through button right ({passThrough.Right}) should fit in panel width ({chooser.ClientSize.Width})."); + Assert.IsLessThanOrEqualTo(chooser.ClientSize.Width, showHidden.Right, + $"Show-hidden checkbox right ({showHidden.Right}) should fit in panel width ({chooser.ClientSize.Width})."); + AssertFloatingPanelsOnScreen(viewer); + } + + private static CheckBox GetChooserCheckBox(IRChooserPanel chooser, string name) { + FieldInfo? field = typeof(IRChooserPanel).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field, $"IRChooserPanel.{name} field should exist."); + return (CheckBox)field.GetValue(chooser)!; + } + + private static void RecipeChooser_RecipeOnlyCheckboxFitsPanelWidthOnShortViewer_Impl() { + var ctx = GraphSessionTestHelper.CreateContext(); + TestDataCacheHelper.SetPresetName(ctx.Cache, "test-preset"); + using var viewer = CreateViewer(ctx, lockedRecipeEditor: false, viewOffset: new Point(0, 0)); + viewer.Size = new Size(600, 280); + + var disconnectedAnchor = new ItemQualityPair(); + viewer.AddNewNode(new Point(10, 10), disconnectedAnchor, new Point(300, 100), NewNodeType.Disconnected); + RecipeChooserPanel? chooser = viewer.Controls.OfType().FirstOrDefault(); + Assert.IsNotNull(chooser); + + MethodInfo? refreshBounds = typeof(IRChooserPanel).GetMethod("RefreshViewerBounds", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(refreshBounds); + refreshBounds.Invoke(chooser, null); + + CheckBox recipeOnly = GetChooserCheckBox(chooser, "RecipeNameOnlyFilterCheckBox"); + Assert.IsTrue(recipeOnly.Visible, "Recipe Only filter should be shown on the full recipe chooser."); + Assert.IsLessThanOrEqualTo(chooser.ClientSize.Width, recipeOnly.Right, + $"Recipe Only checkbox right ({recipeOnly.Right}) should fit in panel width ({chooser.ClientSize.Width}) on a short viewer."); + AssertFloatingPanelsOnScreen(viewer); + } + + private static void RecipeChooser_FooterButtonsScaleWithViewerSize_Impl() { + var ctx = GraphSessionTestHelper.CreateContext(); + TestDataCacheHelper.SetPresetName(ctx.Cache, "test-preset"); + using var viewer = CreateViewer(ctx, lockedRecipeEditor: false, viewOffset: new Point(0, 0)); + + NodeId supplierId = viewer.Session.Editor.CreateSupplierNode(ctx.Item("iron"), new Point(100, 100)); + Assert.IsTrue(viewer.NodeElementDictionary.TryGetValue(supplierId, out BaseNodeElement? supplier)); + Assert.IsNotNull(supplier); + + viewer.AddNewNode(new Point(10, 10), ctx.Item("iron"), new Point(300, 100), NewNodeType.Consumer, supplier); + RecipeChooserPanel? chooser = viewer.Controls.OfType().FirstOrDefault(); + Assert.IsNotNull(chooser); + + MethodInfo? refreshBounds = typeof(IRChooserPanel).GetMethod("RefreshViewerBounds", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(refreshBounds); + + Button passThrough = GetChooserButton(chooser, "AddPassthroughButton"); + Assert.IsTrue(passThrough.Visible); + + int designFooter = ChooserLayout.Scale(chooser, ChooserLayout.DesignFooterButtonHeightPixels); + int minFooter = ChooserLayout.Scale(chooser, ChooserLayout.DesignMinFooterButtonHeightPixels); + int designCell = ChooserLayout.Scale(chooser, ChooserLayout.DesignCellPixels); + float designFont = passThrough.Font.Size; + float minFont = designFont * ChooserLayout.DesignMinFooterButtonFontSizePoints + / ChooserLayout.DesignFooterButtonFontSizePoints; + + (int Width, int Height)[] viewerSizes = [(1200, 800), (600, 360), (320, 240)]; + int[] buttonHeights = new int[viewerSizes.Length]; + float[] fontSizes = new float[viewerSizes.Length]; + int[] cellSizes = new int[viewerSizes.Length]; + for (int i = 0; i < viewerSizes.Length; i++) { + (int viewerWidth, int viewerHeight) = viewerSizes[i]; + viewer.Size = new Size(viewerWidth, viewerHeight); + refreshBounds.Invoke(chooser, null); + + ChooserIconGrid iconGrid = GetChooserIconGrid(chooser); + int cellSize = iconGrid.TargetCellSize; + int expectedHeight = ChooserLayout.FooterButtonHeightForCell(cellSize, designFooter, minFooter); + float expectedFont = ChooserLayout.FooterButtonFontSizeForCell(cellSize, designCell, designFont, minFont); + cellSizes[i] = cellSize; + buttonHeights[i] = passThrough.Height; + fontSizes[i] = passThrough.Font.Size; + + Assert.IsFalse(passThrough.AutoSize, + $"At viewer {viewerWidth}x{viewerHeight}, footer buttons should use explicit layout sizing."); + Assert.AreEqual(expectedHeight, passThrough.Height, + $"At viewer {viewerWidth}x{viewerHeight}, footer height should track icon cell size {cellSize}px."); + Assert.AreEqual(expectedFont, passThrough.Font.Size, 0.05f, + $"At viewer {viewerWidth}x{viewerHeight}, footer font should track icon cell size {cellSize}px."); + Assert.IsLessThanOrEqualTo(chooser.ClientSize.Height, passThrough.Bottom, + $"At viewer {viewerWidth}x{viewerHeight}, Pass-Through bottom ({passThrough.Bottom}) should fit panel height ({chooser.ClientSize.Height})."); + Assert.IsLessThanOrEqualTo(chooser.ClientSize.Width, passThrough.Right, + $"At viewer {viewerWidth}x{viewerHeight}, Pass-Through right ({passThrough.Right}) should fit panel width ({chooser.ClientSize.Width})."); + } + + Assert.IsTrue(cellSizes[0] >= cellSizes[^1], + $"Icon cell size should not grow on shorter viewers (tall={cellSizes[0]}px, short={cellSizes[^1]}px)."); + Assert.IsTrue(buttonHeights[0] >= buttonHeights[^1], + $"Footer button height should track cell shrink (tall={buttonHeights[0]}px, short={buttonHeights[^1]}px)."); + Assert.IsTrue(cellSizes[0] > cellSizes[^1] && buttonHeights[0] > buttonHeights[^1], + $"Short viewer should shrink icon cells and footer buttons (cells {cellSizes[0]}→{cellSizes[^1]}, buttons {buttonHeights[0]}→{buttonHeights[^1]})."); + Assert.IsTrue(fontSizes[0] > fontSizes[^1], + $"Footer font should shrink on shorter viewers (tall={fontSizes[0]}, short={fontSizes[^1]})."); + AssertFloatingPanelsOnScreen(viewer); + } + + private static ChooserIconGrid GetChooserIconGrid(IRChooserPanel chooser) { + FieldInfo? field = typeof(IRChooserPanel).GetField("iconGrid", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field, "IRChooserPanel.iconGrid field should exist."); + return (ChooserIconGrid)field.GetValue(chooser)!; + } + + private static void RecipeChooser_FooterButtonsFitPanelWidth_Impl() { + var ctx = GraphSessionTestHelper.CreateContext(); + TestDataCacheHelper.SetPresetName(ctx.Cache, "test-preset"); + var seed = TestDataCacheHelper.GetOrCreateItem(ctx.Cache, ctx.Subgroup, "seed"); + var spoiled = TestDataCacheHelper.GetOrCreateItem(ctx.Cache, ctx.Subgroup, "spoiled"); + GraphSessionTestHelper.WireSpoilChain(seed, spoiled, ctx.Quality); + GraphSessionTestHelper.CreatePlantProcess(ctx, "seed", "crop"); + + using var viewer = CreateViewer(ctx, lockedRecipeEditor: false, viewOffset: new Point(0, 0)); + ItemQualityPair keyItem = ctx.Item("seed"); + viewer.AddNewNode(new Point(10, 10), keyItem, new Point(200, 150), NewNodeType.Disconnected); + + RecipeChooserPanel? chooser = viewer.Controls.OfType().FirstOrDefault(); + Assert.IsNotNull(chooser); + + FlowLayoutPanel rowB = GetNodeOptionsRowB(chooser); + Button addPlant = GetChooserButton(chooser, "AddPlantButton"); + Assert.IsTrue(addPlant.Visible, "Plant button should be visible for a plantable seed item."); + Assert.IsLessThanOrEqualTo(rowB.ClientSize.Width, addPlant.Right, + $"AddPlantButton right edge ({addPlant.Right}) should fit inside row B width ({rowB.ClientSize.Width})."); + AssertFloatingPanelsOnScreen(viewer); + } + + private static FlowLayoutPanel GetNodeOptionsRowB(IRChooserPanel chooser) { + FieldInfo? field = typeof(IRChooserPanel).GetField("nodeOptionsRowB", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field, "IRChooserPanel.nodeOptionsRowB field should exist."); + return (FlowLayoutPanel)field.GetValue(chooser)!; + } + + private static Button GetChooserButton(IRChooserPanel chooser, string name) { + FieldInfo? field = typeof(IRChooserPanel).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.IsNotNull(field, $"IRChooserPanel.{name} field should exist."); + return (Button)field.GetValue(chooser)!; + } + private static void ItemChooser_SizeMatchesContentAfterShow_Impl() { var ctx = GraphSessionTestHelper.CreateContext(); TestDataCacheHelper.SetPresetName(ctx.Cache, "test-preset"); using var viewer = CreateViewer(ctx, lockedRecipeEditor: false, viewOffset: new Point(0, 0)); viewer.AddItem(new Point(10, 10), new Point(200, 150)); + viewer.PerformLayout(); + Application.DoEvents(); ItemChooserPanel? chooser = viewer.Controls.OfType().FirstOrDefault(); Assert.IsNotNull(chooser); - Assert.AreEqual(chooser.Size, chooser.MaximumSize, - "Chooser should not reserve extra viewer-sized dead space."); + const int margin = EditPanelScreenLayout.DefaultMargin; + int maxPanelHeight = viewer.ClientSize.Height - margin * 2; Assert.IsTrue(chooser.Width >= 200 && chooser.Height >= 200, "Chooser should have a usable size after Show()."); + Assert.IsTrue(chooser.Height <= maxPanelHeight, + $"Chooser height {chooser.Height} should not exceed the graph viewer viewport ({maxPanelHeight}px)."); + Assert.IsNull(chooser.Controls.Find(EditPanelViewportLayout.ScrollHostName, false).FirstOrDefault(), + "Chooser should shrink to fit the viewport instead of using a panel scrollbar."); + Assert.AreEqual(chooser.Size, chooser.MaximumSize, + "Chooser should size tightly to its content."); AssertFloatingPanelsOnScreen(viewer); } @@ -394,7 +676,7 @@ private static void AssertFloatingPanelsOnScreen(ProductionGraphViewer viewer) { const int margin = EditPanelScreenLayout.DefaultMargin; foreach (Control panel in viewer.Controls.Cast().Where(c => c.Visible)) { Rectangle bounds = panel.Bounds; - Assert.IsTrue(EditPanelScreenLayout.FitsViewer(bounds, viewer.Width, viewer.Height, margin), + Assert.IsTrue(EditPanelScreenLayout.FitsViewer(bounds, viewer.ClientSize.Width, viewer.ClientSize.Height, margin), $"Panel {panel.GetType().Name} at {bounds} should be fully inside the viewer."); } }