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.");
}
}