Skip to content
Merged
31 changes: 31 additions & 0 deletions Examples/UICatalog/Scenarios/TableEditor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ public override void Main ()
[
new MenuItem { Title = "_OpenBigExample", Action = () => OpenExample (true) },
new MenuItem { Title = "_OpenSmallExample", Action = () => OpenExample (false) },
new MenuItem { Title = "Open_WideColumnExample", Action = OpenWideColumnExample },
new MenuItem { Title = "OpenCharacter_Map", Action = OpenUnicodeMap },
new MenuItem { Title = "OpenTreeExample", Action = OpenTreeExample },
new MenuItem { Title = "_CloseExample", Action = CloseExample },
Expand Down Expand Up @@ -883,6 +884,36 @@ private void OpenExample (bool big)

private void OpenSimple (bool big) => SetTable (BuildSimpleDataTable (big ? 30 : 5, big ? 1000 : 5));

// Demonstrates the fix for #5072: a column with very wide content used to consume all viewport
// space and push later columns off-screen. With the fix, "Description" is clamped so "Status" and
// "Owner" remain visible at their header widths.
private void OpenWideColumnExample ()
{
DataTable dt = new ();
dt.Columns.Add ("Id", typeof (int));
dt.Columns.Add ("Description", typeof (string));
dt.Columns.Add ("Status", typeof (string));
dt.Columns.Add ("Owner", typeof (string));

string [] statuses = ["Open", "InProgress", "Blocked", "Done"];
string [] owners = ["Alice", "Bob", "Carol", "Dan"];

for (var i = 0; i < 25; i++)
{
dt.Rows.Add (
i,
$"Row {i}: " + new string ('x', 120 + i % 40),
statuses [i % statuses.Length],
owners [i % owners.Length]);
}

SetTable (dt);

// Clear any styles inherited from a previous example
_tableView!.Style.ColumnStyles.Clear ();
_tableView!.Update ();
}

private void OpenTreeExample ()
{
_tableView!.Style.ColumnStyles.Clear ();
Expand Down
88 changes: 85 additions & 3 deletions Terminal.Gui/Views/TableView/TableView.Content.cs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,25 @@ public void EnsureValidScrollOffsets ()

int lastColIdx = nonHiddenColumns.Any () ? nonHiddenColumns.Last ().colIdx : -1;

// Precompute per-column minimum widths and a suffix sum so that "space reserved for remaining
// columns" is O(1) per column during reservation/min-width bookkeeping. Later width calculations
// may still inspect row data.
Comment thread
harder marked this conversation as resolved.
int columnCount = nonHiddenColumns.Count;
int [] minWidths = new int [columnCount];
int [] reservedFromIndex = new int [columnCount + 1];

for (var i = 0; i < columnCount; i++)
{
(int colIdx, ColumnStyle? colStyle) = nonHiddenColumns [i];
minWidths [i] = MinimumWidthFor (colIdx, colStyle);
}

for (int i = columnCount - 1; i >= 0; i--)
{
int separator = i < columnCount - 1 ? 1 : 0;
reservedFromIndex [i] = minWidths [i] + separator + reservedFromIndex [i + 1];
}

//right border
contentSize.Width += Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0;

Expand All @@ -195,6 +214,8 @@ public void EnsureValidScrollOffsets ()
}

// Calculate the content size based on the table's data
var columnIndex = 0;

foreach ((int colIdx, ColumnStyle? colStyle) in nonHiddenColumns)
{
int maxContentSize = CalculateMaxCellWidth (colIdx, colStyle, startRow, rowsToRender) + padding;
Expand All @@ -212,9 +233,6 @@ public void EnsureValidScrollOffsets ()
}
}

// ToDo: MinAcceptableWidth handling?
// if (colStyle is { MinAcceptableWidth: > 0 }

bool isVeryLast = colIdx == lastColIdx;

if (isVeryLast)
Expand All @@ -227,6 +245,22 @@ public void EnsureValidScrollOffsets ()
colWidth = remainingSpace;
}
}
else if (Viewport.Width > 0)
{
// Reserve at least the header width for each subsequent visible column so that a wide
// column does not consume all viewport space and push later columns off-screen.
int reservedForRemaining = reservedFromIndex [columnIndex + 1];
int borderWidth = Style.ShowVerticalHeaderLines || Style.ShowVerticalCellLines ? 1 : 0;
int availableForThisCol = Viewport.Width - contentSize.Width - reservedForRemaining - borderWidth - 1; // -1 for this column's separator
Comment thread
tig marked this conversation as resolved.

// Don't shrink below this column's own minimum (header width or configured minimum)
int thisColMin = minWidths [columnIndex];

if (colWidth > availableForThisCol && availableForThisCol >= thisColMin)
{
colWidth = availableForThisCol;
}
}

columnsToRender.Add (new ColumnToRender (colIdx, contentSize.Width, colWidth + 1, lastColIdx == colIdx));

Expand All @@ -237,6 +271,8 @@ public void EnsureValidScrollOffsets ()
// for separator symbols between columns
contentSize.Width += 1;
}

columnIndex++;
}

// for left border
Expand Down Expand Up @@ -267,4 +303,50 @@ public void EnsureValidScrollOffsets ()

return contentSize;
}

/// <summary>
/// Returns the minimum render width to reserve for a column that has not yet been laid out, based on its header
/// width and any configured minimum (clamped to <see cref="MaxCellWidth"/> and
/// <see cref="ColumnStyle.MaxWidth"/>). This intentionally does not inspect cell data — it is O(1) per column to
/// keep large or paginated <see cref="ITableSource"/> implementations performant.
/// </summary>
private int MinimumWidthFor (int colIdx, ColumnStyle? colStyle)
{
int min = _table!.ColumnNames [colIdx].GetColumns ();

if (min < 1)
{
min = 1;
}

if (colStyle is { MinWidth: > 0 } && colStyle.MinWidth > min)
{
min = colStyle.MinWidth;
}

if (MinCellWidth > 0 && MinCellWidth > min)
{
min = MinCellWidth;
}

// Don't reserve more than the column's own ceiling
int ceiling = MaxCellWidth;

if (colStyle is { } && colStyle.MaxWidth < ceiling)
{
ceiling = colStyle.MaxWidth;
}

if (ceiling < 1)
{
ceiling = 1;
}

if (min > ceiling)
{
min = ceiling;
}

return min;
}
}
111 changes: 111 additions & 0 deletions Tests/UnitTestsParallelizable/Views/TableViewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -549,4 +549,115 @@ public void Test_CalculateMaxCellWidth_UsesGraphemeWidth ()
headerRow
}'");
}

// Copilot
// Verifies fix for #5072: a column with very wide content must not consume all viewport space
// and push later columns off-screen. Each subsequent visible column should be reserved at least
// its header width.
[Fact]
public void Calculate_WideColumn_DoesNotStarveLaterColumns ()
{
DataTable dt = new ();
dt.Columns.Add ("Description");
dt.Columns.Add ("Status");
dt.Columns.Add ("Owner");
dt.Rows.Add (new string ('x', 200), "ok", "me");

using TableView tableView = new ()
{
Table = new DataTableSource (dt),
Viewport = new Rectangle (0, 0, 40, 5)
};
tableView.BeginInit ();
tableView.EndInit ();
tableView.RefreshContentSize ();

TableView.ColumnToRender [] columns = GetColumnsToRender (tableView);

Assert.Equal (3, columns.Length);

// Description must be clamped so that Status and Owner fit
TableView.ColumnToRender description = columns [0];
TableView.ColumnToRender status = columns [1];
TableView.ColumnToRender owner = columns [2];

Assert.True (description.X >= 0);
Assert.True (status.X > description.X);
Assert.True (owner.X > status.X);

// Every column's right edge must lie within the viewport
Assert.True (description.X + description.Width - 1 < tableView.Viewport.Width,
$"Description right edge {description.X + description.Width - 1} exceeds viewport {tableView.Viewport.Width}");
Assert.True (status.X + status.Width - 1 < tableView.Viewport.Width,
$"Status right edge {status.X + status.Width - 1} exceeds viewport {tableView.Viewport.Width}");
Assert.True (owner.X + owner.Width - 1 < tableView.Viewport.Width,
$"Owner right edge {owner.X + owner.Width - 1} exceeds viewport {tableView.Viewport.Width}");

// Status and Owner each must have at least header-width room (excluding separator)
Assert.True (status.Width - 1 >= "Status".Length, $"Status got width {status.Width - 1}");
Assert.True (owner.Width - 1 >= "Owner".Length, $"Owner got width {owner.Width - 1}");
}

// Copilot
// When the viewport is too small to fit even minimum widths for every column, layout falls back
// to the prior left-to-right packing (columns may extend past the viewport, accessible via
// horizontal scrolling).
[Fact]
public void Calculate_NarrowViewport_StillProducesLayout ()
{
DataTable dt = new ();
dt.Columns.Add ("Description");
dt.Columns.Add ("Status");
dt.Columns.Add ("Owner");
dt.Rows.Add (new string ('x', 50), "ok", "me");

using TableView tableView = new ()
{
Table = new DataTableSource (dt),
Viewport = new Rectangle (0, 0, 10, 5)
};
tableView.BeginInit ();
tableView.EndInit ();
tableView.RefreshContentSize ();

TableView.ColumnToRender [] columns = GetColumnsToRender (tableView);

Assert.Equal (3, columns.Length);

// Each column should have a positive width
Assert.All (columns, c => Assert.True (c.Width > 0, $"Column {c.Column} got non-positive width {c.Width}"));
}

// Copilot
// Single-column tables should still expand to fill the viewport when ExpandLastColumn is true.
[Fact]
public void Calculate_SingleColumn_StillExpandsLastColumn ()
{
DataTable dt = new ();
dt.Columns.Add ("Only");
dt.Rows.Add ("hi");

using TableView tableView = new ()
{
Table = new DataTableSource (dt),
Viewport = new Rectangle (0, 0, 30, 5)
};
tableView.BeginInit ();
tableView.EndInit ();
tableView.RefreshContentSize ();

TableView.ColumnToRender [] columns = GetColumnsToRender (tableView);

Assert.Single (columns);
Assert.True (columns [0].Width >= tableView.Viewport.Width - 2,
$"Single column width {columns [0].Width} should fill viewport {tableView.Viewport.Width}");
}

private static TableView.ColumnToRender [] GetColumnsToRender (TableView tableView)
{
FieldInfo? field = typeof (TableView).GetField ("_columnsToRenderCache", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull (field);

return (TableView.ColumnToRender []?)field!.GetValue (tableView) ?? [];
}
}
Loading