From 2e17dd7bc4e166fc1ddfba0f17e9a8ef0bb6c294 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Mon, 26 Oct 2026 08:01:11 -0400 Subject: [PATCH] fix(desktop): size grid columns before they are first painted Grids opened at their designer widths and jumped a moment later, because GridStyle waited for the first paint with rows before measuring them. Columns now measure their header and first 50 rows directly: at handle creation when the rows are already there, otherwise in a call posted when rows arrive, which runs after the filling code and before the repaint. Co-Authored-By: Claude Opus 5.5 --- .../Controls/GridStyleTests.cs | 55 ++++++++++++++ OpenNest/Controls/GridStyle.cs | 74 +++++++++++++++---- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/OpenNest.WinForms.Tests/Controls/GridStyleTests.cs b/OpenNest.WinForms.Tests/Controls/GridStyleTests.cs index 580b5081..53b3c0c9 100644 --- a/OpenNest.WinForms.Tests/Controls/GridStyleTests.cs +++ b/OpenNest.WinForms.Tests/Controls/GridStyleTests.cs @@ -37,6 +37,61 @@ public class GridStyleTests Assert.Equal(77, longColumn.Width); }); + [Fact] + public void RowsFilledBeforeTheFormOpensArePaintedAtTheirFinalWidths() => ArchUnitsTests.RunSta(() => + { + using var form = new Form { Width = 1200, Height = 400, ShowInTaskbar = false }; + var grid = GridStyle.Apply(new DataGridView { Dock = DockStyle.Fill }); + var column = new DataGridViewTextBoxColumn { HeaderText = "Name", Width = 40 }; + grid.Columns.Add(column); + for (var i = 0; i < 20; i++) + grid.Rows.Add(i == 10 ? "A fairly long drawing name" : "A"); + form.Controls.Add(grid); + var firstPaintWidth = WidthAtFirstPaint(grid, column); + + form.Show(); + Application.DoEvents(); + Pump(grid); + + Assert.True(column.Width > 40); + Assert.Equal(column.Width, firstPaintWidth()); + }); + + [Fact] + public void RowsAddedAfterTheFormOpensArePaintedAtTheirFinalWidths() => ArchUnitsTests.RunSta(() => + { + using var form = new Form { Width = 1200, Height = 400, ShowInTaskbar = false }; + var grid = GridStyle.Apply(new DataGridView { Dock = DockStyle.Fill, AllowUserToAddRows = false }); + var column = new DataGridViewTextBoxColumn { HeaderText = "Name", Width = 40 }; + grid.Columns.Add(column); + form.Controls.Add(grid); + form.Show(); + Pump(grid); + + // One row at a time, as most grids fill: the long name is not the first row. + grid.Rows.Add("A"); + grid.Rows.Add("A fairly long drawing name"); + var firstPaintWidth = WidthAtFirstPaint(grid, column); + Application.DoEvents(); + Pump(grid); + + Assert.True(column.Width > 40); + Assert.Equal(column.Width, firstPaintWidth()); + }); + + private static Func WidthAtFirstPaint(DataGridView grid, DataGridViewColumn column) + { + var width = -1; + void OnPaint(object sender, PaintEventArgs e) + { + grid.Paint -= OnPaint; + width = column.Width; + } + + grid.Paint += OnPaint; + return () => width; + } + private static void Pump(Control control) { for (var i = 0; i < 5; i++) diff --git a/OpenNest/Controls/GridStyle.cs b/OpenNest/Controls/GridStyle.cs index d13f1506..9ea58a84 100644 --- a/OpenNest/Controls/GridStyle.cs +++ b/OpenNest/Controls/GridStyle.cs @@ -1,3 +1,4 @@ +using System; using System.Drawing; using System.Windows.Forms; @@ -6,13 +7,14 @@ namespace OpenNest.Controls; /// /// The shared look of every in the desktop app, taken from the /// database nest browser: flat light headers, faint grid lines, a soft blue selection and -/// fixed row heights. Columns size once to their first content and are then the user's to -/// resize. Other behaviour (editing, selection mode) stays with each grid. +/// fixed row heights. Columns size once to their first content, before it is first painted, +/// and are then the user's to resize. Other behaviour (editing, selection mode) stays with each grid. /// public static class GridStyle { public const int RowHeight = 22; private const int MaxInitialColumnWidth = 400; + private const int MeasuredRowCount = 50; private static readonly Color HeaderBackColor = Color.FromArgb(245, 245, 245); private static readonly Color HeaderForeColor = Color.FromArgb(90, 90, 90); @@ -51,30 +53,72 @@ public static class GridStyle } /// - /// Widens each column to fit its header and the rows first shown, once, then leaves the - /// widths to the user. Columns never stretch to fill the grid. + /// Widens each column to fit its header and its first rows, once, then leaves the widths + /// to the user. Columns never stretch to fill the grid. /// private static void SizeColumnsOnceToContent(DataGridView grid) { - // The first paint with rows is the first time they are on screen to be measured. The + // Sizing measures the rows rather than waiting for them on screen, so the grid is first + // painted at its final widths. Rows already there when the handle is created are sized + // at once; rows added later are sized in a posted call, which runs after the adding code + // returns (so a loop of Rows.Add is measured whole) and before the grid repaints. The // empty new-row placeholder does not count: a grid that starts empty keeps its widths // until it has content. - void OnPaint(object sender, PaintEventArgs e) + var pending = false; + + void Detach() { - var newRowShown = grid.NewRowIndex >= 0 && grid.Rows[grid.NewRowIndex].Displayed; - if (grid.DisplayedRowCount(false) - (newRowShown ? 1 : 0) <= 0) + grid.HandleCreated -= OnHandleCreated; + grid.RowsAdded -= OnRowsAdded; + grid.DataBindingComplete -= OnRowsAdded; + } + + void SizeIfFilled() + { + if (grid.IsDisposed || ContentRowCount(grid) == 0) return; - grid.Paint -= OnPaint; + Detach(); + SizeColumnsToRows(grid); + } + + void OnHandleCreated(object sender, EventArgs e) => SizeIfFilled(); + + void OnRowsAdded(object sender, EventArgs e) + { + if (pending || !grid.IsHandleCreated || ContentRowCount(grid) == 0) + return; + pending = true; grid.BeginInvoke(() => { - if (grid.IsDisposed) - return; - grid.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.DisplayedCells); - foreach (DataGridViewColumn column in grid.Columns) - column.Width = System.Math.Min(column.Width, MaxInitialColumnWidth); + pending = false; + SizeIfFilled(); }); } - grid.Paint += OnPaint; + grid.HandleCreated += OnHandleCreated; + grid.RowsAdded += OnRowsAdded; + grid.DataBindingComplete += OnRowsAdded; + if (grid.IsHandleCreated) + SizeIfFilled(); + } + + private static int ContentRowCount(DataGridView grid) => + grid.Rows.Count - (grid.NewRowIndex >= 0 ? 1 : 0); + + private static void SizeColumnsToRows(DataGridView grid) + { + var rowCount = System.Math.Min(ContentRowCount(grid), MeasuredRowCount); + foreach (DataGridViewColumn column in grid.Columns) + { + if (!column.Visible || column.InheritedAutoSizeMode != DataGridViewAutoSizeColumnMode.None) + continue; + var width = column.HeaderCell.PreferredSize.Width; + for (var row = 0; row < rowCount; row++) + { + if (row != grid.NewRowIndex) + width = System.Math.Max(width, grid.Rows[row].Cells[column.Index].PreferredSize.Width); + } + column.Width = System.Math.Clamp(width, column.MinimumWidth, MaxInitialColumnWidth); + } } }