Skip to content

Commit d5ed53e

Browse files
authored
Add CustomTableHeaderLabel formatting to differentiate table header labels that are not property names (PowerShell#17346)
1 parent 50b8f13 commit d5ed53e

File tree

10 files changed

+103
-41
lines changed

10 files changed

+103
-41
lines changed

experimental-feature-linux.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
[
22
"PSCommandNotFoundSuggestion",
3+
"PSCustomTableHeaderLabelDecoration",
34
"PSLoadAssemblyFromNativeCode",
45
"PSNativeCommandErrorActionPreference",
56
"PSSubsystemPluginModel"

experimental-feature-windows.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
[
22
"PSCommandNotFoundSuggestion",
3+
"PSCustomTableHeaderLabelDecoration",
34
"PSLoadAssemblyFromNativeCode",
45
"PSNativeCommandErrorActionPreference",
56
"PSSubsystemPluginModel"

src/System.Management.Automation/FormatAndOutput/common/BaseOutputtingCommand.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1003,16 +1003,18 @@ internal override void Initialize()
10031003
// create arrays for widths and alignment
10041004
Span<int> columnWidths = columns <= StackAllocThreshold ? stackalloc int[columns] : new int[columns];
10051005
Span<int> alignment = columns <= StackAllocThreshold ? stackalloc int[columns] : new int[columns];
1006+
Span<bool> headerMatchesProperty = columns <= StackAllocThreshold ? stackalloc bool[columns] : new bool[columns];
10061007

10071008
int k = 0;
10081009
foreach (TableColumnInfo tci in this.CurrentTableHeaderInfo.tableColumnInfoList)
10091010
{
10101011
columnWidths[k] = (columnWidthsHint != null) ? columnWidthsHint[k] : tci.width;
10111012
alignment[k] = tci.alignment;
1013+
headerMatchesProperty[k] = tci.HeaderMatchesProperty;
10121014
k++;
10131015
}
10141016

1015-
this.Writer.Initialize(0, _consoleWidth, columnWidths, alignment, this.CurrentTableHeaderInfo.hideHeader);
1017+
this.Writer.Initialize(0, _consoleWidth, columnWidths, alignment, headerMatchesProperty, this.CurrentTableHeaderInfo.hideHeader);
10161018
}
10171019

10181020
/// <summary>
@@ -1241,7 +1243,7 @@ internal override void Initialize()
12411243
alignment[k] = TextAlignment.Left;
12421244
}
12431245

1244-
this.Writer.Initialize(0, columnsOnTheScreen, columnWidths, alignment, false, GetConsoleWindowHeight(this.InnerCommand._lo.RowNumber));
1246+
this.Writer.Initialize(leftMarginIndent: 0, columnsOnTheScreen, columnWidths, alignment, headerMatchesProperty: null, suppressHeader: false, screenRows: GetConsoleWindowHeight(this.InnerCommand._lo.RowNumber));
12451247
}
12461248

12471249
/// <summary>

src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator_Table.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,11 @@ private TableHeaderInfo GenerateTableHeaderInfoFromDataBaseInfo(PSObject so)
172172
ci.width = colHeader.width;
173173
ci.alignment = colHeader.alignment;
174174
if (colHeader.label != null)
175+
{
176+
ci.HeaderMatchesProperty = so.Properties[colHeader.label.text] is not null || !ExperimentalFeature.IsEnabled(ExperimentalFeature.PSCustomTableHeaderLabelDecoration);
177+
175178
ci.label = this.dataBaseInfo.db.displayResourceManagerCache.GetTextTokenString(colHeader.label);
179+
}
176180
}
177181

178182
if (ci.alignment == TextAlignment.Undefined)

src/System.Management.Automation/FormatAndOutput/common/FormattingObjects.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ internal sealed partial class TableColumnInfo : FormatInfoData
207207
public int alignment = TextAlignment.Left;
208208
public string label = null;
209209
public string propertyName = null;
210+
public bool HeaderMatchesProperty = true;
210211
}
211212

212213
internal sealed class ListViewHeaderInfo : ShapeInfo

src/System.Management.Automation/FormatAndOutput/common/PSStyle.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,17 @@ public string TableHeader
366366

367367
private string _tableHeader = "\x1b[32;1m";
368368

369+
/// <summary>
370+
/// Gets or sets the style for custom table headers.
371+
/// </summary>
372+
public string CustomTableHeaderLabel
373+
{
374+
get => _customTableHeaderLabel;
375+
set => _customTableHeaderLabel = ValidateNoContent(value);
376+
}
377+
378+
private string _customTableHeaderLabel = "\x1b[32;1;3m";
379+
369380
/// <summary>
370381
/// Gets or sets the accent style for errors.
371382
/// </summary>

src/System.Management.Automation/FormatAndOutput/common/TableWriter.cs

Lines changed: 46 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ private sealed class ColumnInfo
2222
internal int startCol = 0;
2323
internal int width = 0;
2424
internal int alignment = TextAlignment.Left;
25+
internal bool HeaderMatchesProperty = true;
2526
}
2627
/// <summary>
2728
/// Class containing information about the tabular layout.
@@ -82,9 +83,10 @@ internal static int ComputeWideViewBestItemsPerRowFit(int stringLen, int screenC
8283
/// <param name="screenColumns">Number of character columns on the screen.</param>
8384
/// <param name="columnWidths">Array of specified column widths.</param>
8485
/// <param name="alignment">Array of alignment flags.</param>
86+
/// <param name="headerMatchesProperty">Array of flags where the header label matches a property name.</param>
8587
/// <param name="suppressHeader">If true, suppress header printing.</param>
8688
/// <param name="screenRows">Number of rows on the screen.</param>
87-
internal void Initialize(int leftMarginIndent, int screenColumns, Span<int> columnWidths, ReadOnlySpan<int> alignment, bool suppressHeader, int screenRows = int.MaxValue)
89+
internal void Initialize(int leftMarginIndent, int screenColumns, Span<int> columnWidths, ReadOnlySpan<int> alignment, ReadOnlySpan<bool> headerMatchesProperty, bool suppressHeader, int screenRows = int.MaxValue)
8890
{
8991
if (leftMarginIndent < 0)
9092
{
@@ -139,6 +141,11 @@ internal void Initialize(int leftMarginIndent, int screenColumns, Span<int> colu
139141
_si.columnInfo[k].startCol = startCol;
140142
_si.columnInfo[k].width = columnWidths[k];
141143
_si.columnInfo[k].alignment = alignment[k];
144+
if (!headerMatchesProperty.IsEmpty)
145+
{
146+
_si.columnInfo[k].HeaderMatchesProperty = headerMatchesProperty[k];
147+
}
148+
142149
startCol += columnWidths[k] + ScreenInfo.separatorCharacterCount;
143150
}
144151
}
@@ -156,7 +163,7 @@ internal int GenerateHeader(string[] values, LineOutput lo)
156163

157164
foreach (string line in _header)
158165
{
159-
lo.WriteLine(style == string.Empty ? line : style + line + reset);
166+
lo.WriteLine(line);
160167
}
161168

162169
return _header.Count;
@@ -234,35 +241,21 @@ internal void GenerateRow(string[] values, LineOutput lo, bool multiLine, ReadOn
234241

235242
if (multiLine)
236243
{
237-
foreach (string line in GenerateTableRow(values, currentAlignment, lo.DisplayCells))
244+
foreach (string line in GenerateTableRow(values, currentAlignment, lo.DisplayCells, isHeader))
238245
{
239246
generatedRows?.Add(line);
240-
if (isHeader)
241-
{
242-
lo.WriteLine(style == string.Empty ? line : style + line + reset);
243-
}
244-
else
245-
{
246-
lo.WriteLine(line);
247-
}
247+
lo.WriteLine(line);
248248
}
249249
}
250250
else
251251
{
252-
string line = GenerateRow(values, currentAlignment, dc);
252+
string line = GenerateRow(values, currentAlignment, dc, isHeader);
253253
generatedRows?.Add(line);
254-
if (isHeader)
255-
{
256-
lo.WriteLine(style == string.Empty ? line : style + line + reset);
257-
}
258-
else
259-
{
260-
lo.WriteLine(line);
261-
}
254+
lo.WriteLine(line);
262255
}
263256
}
264257

265-
private string[] GenerateTableRow(string[] values, ReadOnlySpan<int> alignment, DisplayCells ds)
258+
private string[] GenerateTableRow(string[] values, ReadOnlySpan<int> alignment, DisplayCells ds, bool isHeader)
266259
{
267260
// select the active columns (skip hidden ones)
268261
Span<int> validColumnArray = _si.columnInfo.Length <= OutCommandInner.StackAllocThreshold ? stackalloc int[_si.columnInfo.Length] : new int[_si.columnInfo.Length];
@@ -291,8 +284,7 @@ private string[] GenerateTableRow(string[] values, ReadOnlySpan<int> alignment,
291284
}
292285

293286
// obtain a set of tokens for each field
294-
scArray[k] = GenerateMultiLineRowField(values[validColumnArray[k]], validColumnArray[k],
295-
alignment[validColumnArray[k]], ds, addPadding);
287+
scArray[k] = GenerateMultiLineRowField(values[validColumnArray[k]], validColumnArray[k], alignment[validColumnArray[k]], ds, addPadding);
296288

297289
// NOTE: the following padding operations assume that we
298290
// pad with a blank (or any character that ALWAYS maps to a single screen cell
@@ -323,7 +315,9 @@ private string[] GenerateTableRow(string[] values, ReadOnlySpan<int> alignment,
323315
for (int k = 0; k < scArray.Length; k++)
324316
{
325317
if (scArray[k].Count > screenRows)
318+
{
326319
screenRows = scArray[k].Count;
320+
}
327321
}
328322

329323
// column headers can span multiple rows if the width of the column is shorter than the header text like:
@@ -335,7 +329,6 @@ private string[] GenerateTableRow(string[] values, ReadOnlySpan<int> alignment,
335329
// 1 2 3
336330
//
337331
// To ensure we don't add whitespace to the end, we need to determine the last column in each row with content
338-
339332
System.Span<int> lastColWithContent = screenRows <= OutCommandInner.StackAllocThreshold ? stackalloc int[screenRows] : new int[screenRows];
340333
for (int row = 0; row < screenRows; row++)
341334
{
@@ -390,17 +383,36 @@ private string[] GenerateTableRow(string[] values, ReadOnlySpan<int> alignment,
390383
for (int row = 0; row < screenRows; row++)
391384
{
392385
StringBuilder sb = new StringBuilder();
386+
393387
// for a given row, walk the columns
394388
for (int col = 0; col < scArray.Length; col++)
395389
{
390+
string value = scArray[col][row];
391+
396392
// if the column is the last column with content, we need to trim trailing whitespace, unless there is only one row
397393
if (col == lastColWithContent[row] && screenRows > 1)
398394
{
399-
sb.Append(scArray[col][row].TrimEnd());
395+
value = value.TrimEnd();
400396
}
401-
else
397+
398+
if (isHeader)
402399
{
403-
sb.Append(scArray[col][row]);
400+
if (_si.columnInfo[col].HeaderMatchesProperty)
401+
{
402+
sb.Append(PSStyle.Instance.Formatting.TableHeader);
403+
}
404+
else if (value.Length > 0)
405+
{
406+
// after the first column, each additional column starts with a whitespace for separation
407+
value = value.Insert(col == 0 ? 0 : 1, PSStyle.Instance.Formatting.CustomTableHeaderLabel);
408+
}
409+
}
410+
411+
sb.Append(value);
412+
413+
if (isHeader)
414+
{
415+
sb.Append(PSStyle.Instance.Reset);
404416
}
405417
}
406418

@@ -427,7 +439,7 @@ private StringCollection GenerateMultiLineRowField(string val, int k, int alignm
427439
return sc;
428440
}
429441

430-
private string GenerateRow(string[] values, ReadOnlySpan<int> alignment, DisplayCells dc)
442+
private string GenerateRow(string[] values, ReadOnlySpan<int> alignment, DisplayCells dc, bool isHeader)
431443
{
432444
StringBuilder sb = new StringBuilder();
433445

@@ -462,9 +474,14 @@ private string GenerateRow(string[] values, ReadOnlySpan<int> alignment, Display
462474
}
463475

464476
string rowField = GenerateRowField(values[k], _si.columnInfo[k].width, alignment[k], dc, addPadding);
477+
if (isHeader)
478+
{
479+
sb.Append(PSStyle.Instance.Formatting.TableHeader);
480+
}
481+
465482
sb.Append(rowField);
466483

467-
if (rowField is not null && rowField.Contains(ValueStringDecorated.ESC) && !rowField.AsSpan().TrimEnd().EndsWith(PSStyle.Instance.Reset))
484+
if (isHeader || (rowField is not null && rowField.Contains(ValueStringDecorated.ESC) && !rowField.AsSpan().TrimEnd().EndsWith(PSStyle.Instance.Reset)))
468485
{
469486
// Reset the console output if the content of this column contains ESC
470487
sb.Append(PSStyle.Instance.Reset);

src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public class ExperimentalFeature
2222

2323
internal const string EngineSource = "PSEngine";
2424
internal const string PSNativeCommandErrorActionPreferenceFeatureName = "PSNativeCommandErrorActionPreference";
25+
internal const string PSCustomTableHeaderLabelDecoration = "PSCustomTableHeaderLabelDecoration";
2526

2627
#endregion
2728

@@ -116,6 +117,9 @@ static ExperimentalFeature()
116117
new ExperimentalFeature(
117118
name: PSNativeCommandErrorActionPreferenceFeatureName,
118119
description: "Native commands with non-zero exit codes issue errors according to $ErrorActionPreference when $PSNativeCommandUseErrorActionPreference is $true"),
120+
new ExperimentalFeature(
121+
name: PSCustomTableHeaderLabelDecoration,
122+
description: "Formatting differentiation for table header labels that aren't property members"),
119123
};
120124

121125
EngineExperimentalFeatures = new ReadOnlyCollection<ExperimentalFeature>(engineFeatures);

test/powershell/Modules/Microsoft.PowerShell.Utility/Format-Table.Tests.ps1

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -876,10 +876,10 @@ Describe 'Table color tests' {
876876
$PSStyle.OutputRendering = $originalRendering
877877
}
878878

879-
It 'Table header should use FormatAccent' {
879+
It 'Table header should use TableHeader' {
880880
([pscustomobject]@{foo = 1} | Format-Table | Out-String).Trim() | Should -BeExactly @"
881-
$($PSStyle.Formatting.FormatAccent)foo$($PSStyle.Reset)
882-
$($PSStyle.Formatting.FormatAccent)---$($PSStyle.Reset)
881+
$($PSStyle.Formatting.TableHeader)foo$($PSStyle.Reset)
882+
$($PSStyle.Formatting.TableHeader)---$($PSStyle.Reset)
883883
1
884884
"@
885885
}

test/powershell/engine/Formatting/PSStyle.Tests.ps1

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Describe 'Tests for $PSStyle automatic variable' {
2424
$formattingDefaults = @{
2525
FormatAccent = "`e[32;1m"
2626
TableHeader = "`e[32;1m"
27+
CustomTableHeaderLabel = "`e[32;1;3m"
2728
ErrorAccent = "`e[36;1m"
2829
Error = "`e[31;1m"
2930
Debug = "`e[33;1m"
@@ -191,14 +192,33 @@ Describe 'Tests for $PSStyle automatic variable' {
191192
$PSStyle.OutputRendering = 'Ansi'
192193
$PSStyle.Formatting.TableHeader = ''
193194
$out = $PSVersionTable | Format-Table | Out-String
194-
$out.Contains("`e[") | Should -BeFalse
195+
$out.Replace($PSStyle.Reset,'').Contains("`e[") | Should -BeFalse
195196
}
196197
finally {
197198
$PSStyle.OutputRendering = $oldRender
198199
$PSStyle.Formatting.TableHeader = $old
199200
}
200201
}
201202

203+
It '$PSStyle.Formatting.CustomTableHeaderLabel is applied to Format-Table' {
204+
$old = $PSStyle.Formatting.CustomTableHeaderLabel
205+
$oldRender = $PSStyle.OutputRendering
206+
207+
try {
208+
$PSStyle.OutputRendering = 'Ansi'
209+
$PSStyle.Formatting.CustomTableHeaderLabel = $PSStyle.Foreground.Blue + $PSStyle.Background.White + $PSStyle.Bold
210+
$out = Get-Process pwsh | Select-Object -First 1 | Format-Table | Out-String
211+
$format = $PSStyle.Formatting.CustomTableHeaderLabel.Replace('[',"``[")
212+
$header = $PSStyle.Formatting.TableHeader.Replace('[',"``[")
213+
$reset = $PSStyle.Reset.Replace('[',"``[")
214+
$out | Should -BeLike "*${format}*NPM(K)${reset}*${format}*PM(M)${reset}*${format}*WS(M)${reset}*${format}*CPU(s)${reset}*${header}*Id${reset}*${header}*SI${reset}*${header}*ProcessName${reset}*"
215+
}
216+
finally {
217+
$PSStyle.OutputRendering = $oldRender
218+
$PSStyle.Formatting.CustomTableHeaderLabel = $old
219+
}
220+
}
221+
202222
It 'Should fail if setting formatting contains printable characters: <member>.<submember>' -TestCases @(
203223
@{ Submember = 'Reset' }
204224
@{ Submember = 'BlinkOff' }
@@ -215,6 +235,7 @@ Describe 'Tests for $PSStyle automatic variable' {
215235
@{ Submember = 'Strikethrough' }
216236
@{ Member = 'Formatting'; Submember = 'FormatAccent' }
217237
@{ Member = 'Formatting'; Submember = 'TableHeader' }
238+
@{ Member = 'Formatting'; Submember = 'CustomTableHeaderLabel' }
218239
@{ Member = 'Formatting'; Submember = 'ErrorAccent' }
219240
@{ Member = 'Formatting'; Submember = 'Error' }
220241
@{ Member = 'Formatting'; Submember = 'Warning' }
@@ -380,8 +401,8 @@ Describe 'Handle strings with escape sequences in formatting' {
380401

381402
It 'Truncation for strings with no escape sequences' {
382403
$expected = @"
383-
`e[32;1mName Role YIR`e[0m
384-
`e[32;1m---- ---- ---`e[0m
404+
`e[32;1mName `e[0m`e[32;1m Role `e[0m`e[32;1m YIR`e[0m
405+
`e[32;1m---- `e[0m `e[32;1m---- `e[0m `e[32;1m---`e[0m
385406
Bob Saggat Developer 2
386407
John Seym… Sw Engineer 6
387408
Billy Bob… Senior DevOps … 13
@@ -398,8 +419,8 @@ Billy Bob… Senior DevOps … 13
398419

399420
It "Truncation for strings with escape sequences - TableView-1" {
400421
$expected = @"
401-
`e[32;1mName Role YIR`e[0m
402-
`e[32;1m---- ---- ---`e[0m
422+
`e[32;1mName `e[0m`e[32;1m Role `e[0m`e[32;1m YIR`e[0m
423+
`e[32;1m---- `e[0m `e[32;1m---- `e[0m `e[32;1m---`e[0m
403424
`e[32mBob Saggat`e[39m`e[0m Developer 2
404425
`e[33mJohn Seym…`e[0m Sw Engineer 6
405426
`e[31mBilly Bob…`e[0m Senior DevOps … 13
@@ -420,8 +441,8 @@ Billy Bob… Senior DevOps … 13
420441

421442
It "Truncation for strings with escape sequences - TableView-2" {
422443
$expected = @"
423-
`e[32;1mName Role YIR`e[0m
424-
`e[32;1m---- ---- ---`e[0m
444+
`e[32;1mName `e[0m`e[32;1m Role `e[0m`e[32;1m YIR`e[0m
445+
`e[32;1m---- `e[0m `e[32;1m---- `e[0m `e[32;1m---`e[0m
425446
`e[32mBob Saggat`e[39m`e[0m Developer`e[0m 2
426447
`e[33mJohn Seym…`e[0m `e[1;33mSw Engineer`e[0m 6
427448
`e[31mBilly Bob…`e[0m `e[42m`e[1;33mSenior DevOps …`e[0m 13

0 commit comments

Comments
 (0)