diff --git a/experimental-feature-linux.json b/experimental-feature-linux.json index 4dee94965..573cfc108 100644 --- a/experimental-feature-linux.json +++ b/experimental-feature-linux.json @@ -1,5 +1,6 @@ [ "PSCommandNotFoundSuggestion", + "PSCustomTableHeaderLabelDecoration", "PSLoadAssemblyFromNativeCode", "PSNativeCommandErrorActionPreference", "PSSubsystemPluginModel" diff --git a/experimental-feature-windows.json b/experimental-feature-windows.json index 4dee94965..573cfc108 100644 --- a/experimental-feature-windows.json +++ b/experimental-feature-windows.json @@ -1,5 +1,6 @@ [ "PSCommandNotFoundSuggestion", + "PSCustomTableHeaderLabelDecoration", "PSLoadAssemblyFromNativeCode", "PSNativeCommandErrorActionPreference", "PSSubsystemPluginModel" diff --git a/src/System.Management.Automation/FormatAndOutput/common/BaseOutputtingCommand.cs b/src/System.Management.Automation/FormatAndOutput/common/BaseOutputtingCommand.cs index c0c932142..088a6a76b 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/BaseOutputtingCommand.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/BaseOutputtingCommand.cs @@ -1003,16 +1003,18 @@ namespace Microsoft.PowerShell.Commands.Internal.Format // create arrays for widths and alignment Span columnWidths = columns <= StackAllocThreshold ? stackalloc int[columns] : new int[columns]; Span alignment = columns <= StackAllocThreshold ? stackalloc int[columns] : new int[columns]; + Span headerMatchesProperty = columns <= StackAllocThreshold ? stackalloc bool[columns] : new bool[columns]; int k = 0; foreach (TableColumnInfo tci in this.CurrentTableHeaderInfo.tableColumnInfoList) { columnWidths[k] = (columnWidthsHint != null) ? columnWidthsHint[k] : tci.width; alignment[k] = tci.alignment; + headerMatchesProperty[k] = tci.HeaderMatchesProperty; k++; } - this.Writer.Initialize(0, _consoleWidth, columnWidths, alignment, this.CurrentTableHeaderInfo.hideHeader); + this.Writer.Initialize(0, _consoleWidth, columnWidths, alignment, headerMatchesProperty, this.CurrentTableHeaderInfo.hideHeader); } /// @@ -1241,7 +1243,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format alignment[k] = TextAlignment.Left; } - this.Writer.Initialize(0, columnsOnTheScreen, columnWidths, alignment, false, GetConsoleWindowHeight(this.InnerCommand._lo.RowNumber)); + this.Writer.Initialize(leftMarginIndent: 0, columnsOnTheScreen, columnWidths, alignment, headerMatchesProperty: null, suppressHeader: false, screenRows: GetConsoleWindowHeight(this.InnerCommand._lo.RowNumber)); } /// diff --git a/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator_Table.cs b/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator_Table.cs index b0efa32dc..7c661a516 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator_Table.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/FormatViewGenerator_Table.cs @@ -172,7 +172,11 @@ namespace Microsoft.PowerShell.Commands.Internal.Format ci.width = colHeader.width; ci.alignment = colHeader.alignment; if (colHeader.label != null) + { + ci.HeaderMatchesProperty = so.Properties[colHeader.label.text] is not null || !ExperimentalFeature.IsEnabled(ExperimentalFeature.PSCustomTableHeaderLabelDecoration); + ci.label = this.dataBaseInfo.db.displayResourceManagerCache.GetTextTokenString(colHeader.label); + } } if (ci.alignment == TextAlignment.Undefined) diff --git a/src/System.Management.Automation/FormatAndOutput/common/FormattingObjects.cs b/src/System.Management.Automation/FormatAndOutput/common/FormattingObjects.cs index f4ac55d1f..6c999ae33 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/FormattingObjects.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/FormattingObjects.cs @@ -207,6 +207,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format public int alignment = TextAlignment.Left; public string label = null; public string propertyName = null; + public bool HeaderMatchesProperty = true; } internal sealed class ListViewHeaderInfo : ShapeInfo diff --git a/src/System.Management.Automation/FormatAndOutput/common/PSStyle.cs b/src/System.Management.Automation/FormatAndOutput/common/PSStyle.cs index a21706341..b6e7b9370 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/PSStyle.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/PSStyle.cs @@ -366,6 +366,17 @@ namespace System.Management.Automation private string _tableHeader = "\x1b[32;1m"; + /// + /// Gets or sets the style for custom table headers. + /// + public string CustomTableHeaderLabel + { + get => _customTableHeaderLabel; + set => _customTableHeaderLabel = ValidateNoContent(value); + } + + private string _customTableHeaderLabel = "\x1b[32;1;3m"; + /// /// Gets or sets the accent style for errors. /// diff --git a/src/System.Management.Automation/FormatAndOutput/common/TableWriter.cs b/src/System.Management.Automation/FormatAndOutput/common/TableWriter.cs index e21ef5b20..49b9005a8 100644 --- a/src/System.Management.Automation/FormatAndOutput/common/TableWriter.cs +++ b/src/System.Management.Automation/FormatAndOutput/common/TableWriter.cs @@ -22,6 +22,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format internal int startCol = 0; internal int width = 0; internal int alignment = TextAlignment.Left; + internal bool HeaderMatchesProperty = true; } /// /// Class containing information about the tabular layout. @@ -82,9 +83,10 @@ namespace Microsoft.PowerShell.Commands.Internal.Format /// Number of character columns on the screen. /// Array of specified column widths. /// Array of alignment flags. + /// Array of flags where the header label matches a property name. /// If true, suppress header printing. /// Number of rows on the screen. - internal void Initialize(int leftMarginIndent, int screenColumns, Span columnWidths, ReadOnlySpan alignment, bool suppressHeader, int screenRows = int.MaxValue) + internal void Initialize(int leftMarginIndent, int screenColumns, Span columnWidths, ReadOnlySpan alignment, ReadOnlySpan headerMatchesProperty, bool suppressHeader, int screenRows = int.MaxValue) { if (leftMarginIndent < 0) { @@ -139,6 +141,11 @@ namespace Microsoft.PowerShell.Commands.Internal.Format _si.columnInfo[k].startCol = startCol; _si.columnInfo[k].width = columnWidths[k]; _si.columnInfo[k].alignment = alignment[k]; + if (!headerMatchesProperty.IsEmpty) + { + _si.columnInfo[k].HeaderMatchesProperty = headerMatchesProperty[k]; + } + startCol += columnWidths[k] + ScreenInfo.separatorCharacterCount; } } @@ -156,7 +163,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format foreach (string line in _header) { - lo.WriteLine(style == string.Empty ? line : style + line + reset); + lo.WriteLine(line); } return _header.Count; @@ -234,35 +241,21 @@ namespace Microsoft.PowerShell.Commands.Internal.Format if (multiLine) { - foreach (string line in GenerateTableRow(values, currentAlignment, lo.DisplayCells)) + foreach (string line in GenerateTableRow(values, currentAlignment, lo.DisplayCells, isHeader)) { generatedRows?.Add(line); - if (isHeader) - { - lo.WriteLine(style == string.Empty ? line : style + line + reset); - } - else - { - lo.WriteLine(line); - } + lo.WriteLine(line); } } else { - string line = GenerateRow(values, currentAlignment, dc); + string line = GenerateRow(values, currentAlignment, dc, isHeader); generatedRows?.Add(line); - if (isHeader) - { - lo.WriteLine(style == string.Empty ? line : style + line + reset); - } - else - { - lo.WriteLine(line); - } + lo.WriteLine(line); } } - private string[] GenerateTableRow(string[] values, ReadOnlySpan alignment, DisplayCells ds) + private string[] GenerateTableRow(string[] values, ReadOnlySpan alignment, DisplayCells ds, bool isHeader) { // select the active columns (skip hidden ones) Span validColumnArray = _si.columnInfo.Length <= OutCommandInner.StackAllocThreshold ? stackalloc int[_si.columnInfo.Length] : new int[_si.columnInfo.Length]; @@ -291,8 +284,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format } // obtain a set of tokens for each field - scArray[k] = GenerateMultiLineRowField(values[validColumnArray[k]], validColumnArray[k], - alignment[validColumnArray[k]], ds, addPadding); + scArray[k] = GenerateMultiLineRowField(values[validColumnArray[k]], validColumnArray[k], alignment[validColumnArray[k]], ds, addPadding); // NOTE: the following padding operations assume that we // pad with a blank (or any character that ALWAYS maps to a single screen cell @@ -323,7 +315,9 @@ namespace Microsoft.PowerShell.Commands.Internal.Format for (int k = 0; k < scArray.Length; k++) { if (scArray[k].Count > screenRows) + { screenRows = scArray[k].Count; + } } // column headers can span multiple rows if the width of the column is shorter than the header text like: @@ -335,7 +329,6 @@ namespace Microsoft.PowerShell.Commands.Internal.Format // 1 2 3 // // To ensure we don't add whitespace to the end, we need to determine the last column in each row with content - System.Span lastColWithContent = screenRows <= OutCommandInner.StackAllocThreshold ? stackalloc int[screenRows] : new int[screenRows]; for (int row = 0; row < screenRows; row++) { @@ -390,17 +383,36 @@ namespace Microsoft.PowerShell.Commands.Internal.Format for (int row = 0; row < screenRows; row++) { StringBuilder sb = new StringBuilder(); + // for a given row, walk the columns for (int col = 0; col < scArray.Length; col++) { + string value = scArray[col][row]; + // if the column is the last column with content, we need to trim trailing whitespace, unless there is only one row if (col == lastColWithContent[row] && screenRows > 1) { - sb.Append(scArray[col][row].TrimEnd()); + value = value.TrimEnd(); } - else + + if (isHeader) { - sb.Append(scArray[col][row]); + if (_si.columnInfo[col].HeaderMatchesProperty) + { + sb.Append(PSStyle.Instance.Formatting.TableHeader); + } + else if (value.Length > 0) + { + // after the first column, each additional column starts with a whitespace for separation + value = value.Insert(col == 0 ? 0 : 1, PSStyle.Instance.Formatting.CustomTableHeaderLabel); + } + } + + sb.Append(value); + + if (isHeader) + { + sb.Append(PSStyle.Instance.Reset); } } @@ -427,7 +439,7 @@ namespace Microsoft.PowerShell.Commands.Internal.Format return sc; } - private string GenerateRow(string[] values, ReadOnlySpan alignment, DisplayCells dc) + private string GenerateRow(string[] values, ReadOnlySpan alignment, DisplayCells dc, bool isHeader) { StringBuilder sb = new StringBuilder(); @@ -462,9 +474,14 @@ namespace Microsoft.PowerShell.Commands.Internal.Format } string rowField = GenerateRowField(values[k], _si.columnInfo[k].width, alignment[k], dc, addPadding); + if (isHeader) + { + sb.Append(PSStyle.Instance.Formatting.TableHeader); + } + sb.Append(rowField); - if (rowField is not null && rowField.Contains(ValueStringDecorated.ESC) && !rowField.AsSpan().TrimEnd().EndsWith(PSStyle.Instance.Reset)) + if (isHeader || (rowField is not null && rowField.Contains(ValueStringDecorated.ESC) && !rowField.AsSpan().TrimEnd().EndsWith(PSStyle.Instance.Reset))) { // Reset the console output if the content of this column contains ESC sb.Append(PSStyle.Instance.Reset); diff --git a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs index 8eaf38220..d83bd4bc8 100644 --- a/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs +++ b/src/System.Management.Automation/engine/ExperimentalFeature/ExperimentalFeature.cs @@ -22,6 +22,7 @@ namespace System.Management.Automation internal const string EngineSource = "PSEngine"; internal const string PSNativeCommandErrorActionPreferenceFeatureName = "PSNativeCommandErrorActionPreference"; + internal const string PSCustomTableHeaderLabelDecoration = "PSCustomTableHeaderLabelDecoration"; #endregion @@ -116,6 +117,9 @@ namespace System.Management.Automation new ExperimentalFeature( name: PSNativeCommandErrorActionPreferenceFeatureName, description: "Native commands with non-zero exit codes issue errors according to $ErrorActionPreference when $PSNativeCommandUseErrorActionPreference is $true"), + new ExperimentalFeature( + name: PSCustomTableHeaderLabelDecoration, + description: "Formatting differentiation for table header labels that aren't property members"), }; EngineExperimentalFeatures = new ReadOnlyCollection(engineFeatures); diff --git a/test/powershell/Modules/Microsoft.PowerShell.Utility/Format-Table.Tests.ps1 b/test/powershell/Modules/Microsoft.PowerShell.Utility/Format-Table.Tests.ps1 index 47364842b..67933d280 100644 --- a/test/powershell/Modules/Microsoft.PowerShell.Utility/Format-Table.Tests.ps1 +++ b/test/powershell/Modules/Microsoft.PowerShell.Utility/Format-Table.Tests.ps1 @@ -876,10 +876,10 @@ Describe 'Table color tests' { $PSStyle.OutputRendering = $originalRendering } - It 'Table header should use FormatAccent' { + It 'Table header should use TableHeader' { ([pscustomobject]@{foo = 1} | Format-Table | Out-String).Trim() | Should -BeExactly @" -$($PSStyle.Formatting.FormatAccent)foo$($PSStyle.Reset) -$($PSStyle.Formatting.FormatAccent)---$($PSStyle.Reset) +$($PSStyle.Formatting.TableHeader)foo$($PSStyle.Reset) +$($PSStyle.Formatting.TableHeader)---$($PSStyle.Reset) 1 "@ } diff --git a/test/powershell/engine/Formatting/PSStyle.Tests.ps1 b/test/powershell/engine/Formatting/PSStyle.Tests.ps1 index e232ebe6d..c1da9b6b6 100644 --- a/test/powershell/engine/Formatting/PSStyle.Tests.ps1 +++ b/test/powershell/engine/Formatting/PSStyle.Tests.ps1 @@ -24,6 +24,7 @@ Describe 'Tests for $PSStyle automatic variable' { $formattingDefaults = @{ FormatAccent = "`e[32;1m" TableHeader = "`e[32;1m" + CustomTableHeaderLabel = "`e[32;1;3m" ErrorAccent = "`e[36;1m" Error = "`e[31;1m" Debug = "`e[33;1m" @@ -191,7 +192,7 @@ Describe 'Tests for $PSStyle automatic variable' { $PSStyle.OutputRendering = 'Ansi' $PSStyle.Formatting.TableHeader = '' $out = $PSVersionTable | Format-Table | Out-String - $out.Contains("`e[") | Should -BeFalse + $out.Replace($PSStyle.Reset,'').Contains("`e[") | Should -BeFalse } finally { $PSStyle.OutputRendering = $oldRender @@ -199,6 +200,25 @@ Describe 'Tests for $PSStyle automatic variable' { } } + It '$PSStyle.Formatting.CustomTableHeaderLabel is applied to Format-Table' { + $old = $PSStyle.Formatting.CustomTableHeaderLabel + $oldRender = $PSStyle.OutputRendering + + try { + $PSStyle.OutputRendering = 'Ansi' + $PSStyle.Formatting.CustomTableHeaderLabel = $PSStyle.Foreground.Blue + $PSStyle.Background.White + $PSStyle.Bold + $out = Get-Process pwsh | Select-Object -First 1 | Format-Table | Out-String + $format = $PSStyle.Formatting.CustomTableHeaderLabel.Replace('[',"``[") + $header = $PSStyle.Formatting.TableHeader.Replace('[',"``[") + $reset = $PSStyle.Reset.Replace('[',"``[") + $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}*" + } + finally { + $PSStyle.OutputRendering = $oldRender + $PSStyle.Formatting.CustomTableHeaderLabel = $old + } + } + It 'Should fail if setting formatting contains printable characters: .' -TestCases @( @{ Submember = 'Reset' } @{ Submember = 'BlinkOff' } @@ -215,6 +235,7 @@ Describe 'Tests for $PSStyle automatic variable' { @{ Submember = 'Strikethrough' } @{ Member = 'Formatting'; Submember = 'FormatAccent' } @{ Member = 'Formatting'; Submember = 'TableHeader' } + @{ Member = 'Formatting'; Submember = 'CustomTableHeaderLabel' } @{ Member = 'Formatting'; Submember = 'ErrorAccent' } @{ Member = 'Formatting'; Submember = 'Error' } @{ Member = 'Formatting'; Submember = 'Warning' } @@ -380,8 +401,8 @@ Describe 'Handle strings with escape sequences in formatting' { It 'Truncation for strings with no escape sequences' { $expected = @" -`e[32;1mName Role YIR`e[0m -`e[32;1m---- ---- ---`e[0m +`e[32;1mName `e[0m`e[32;1m Role `e[0m`e[32;1m YIR`e[0m +`e[32;1m---- `e[0m `e[32;1m---- `e[0m `e[32;1m---`e[0m Bob Saggat Developer 2 John Seym… Sw Engineer 6 Billy Bob… Senior DevOps … 13 @@ -398,8 +419,8 @@ Billy Bob… Senior DevOps … 13 It "Truncation for strings with escape sequences - TableView-1" { $expected = @" -`e[32;1mName Role YIR`e[0m -`e[32;1m---- ---- ---`e[0m +`e[32;1mName `e[0m`e[32;1m Role `e[0m`e[32;1m YIR`e[0m +`e[32;1m---- `e[0m `e[32;1m---- `e[0m `e[32;1m---`e[0m `e[32mBob Saggat`e[39m`e[0m Developer 2 `e[33mJohn Seym…`e[0m Sw Engineer 6 `e[31mBilly Bob…`e[0m Senior DevOps … 13 @@ -420,8 +441,8 @@ Billy Bob… Senior DevOps … 13 It "Truncation for strings with escape sequences - TableView-2" { $expected = @" -`e[32;1mName Role YIR`e[0m -`e[32;1m---- ---- ---`e[0m +`e[32;1mName `e[0m`e[32;1m Role `e[0m`e[32;1m YIR`e[0m +`e[32;1m---- `e[0m `e[32;1m---- `e[0m `e[32;1m---`e[0m `e[32mBob Saggat`e[39m`e[0m Developer`e[0m 2 `e[33mJohn Seym…`e[0m `e[1;33mSw Engineer`e[0m 6 `e[31mBilly Bob…`e[0m `e[42m`e[1;33mSenior DevOps …`e[0m 13