Public/Write-ColorEX.ps1
|
Function Write-ColorEX { <# .SYNOPSIS Writes colored and styled text to the host, with optional padding, alignment and logging to a file. .DESCRIPTION Write-ColorEX writes text through Write-Host with more color, style and layout options: - Color modes: the console's 16 colors, ANSI 4-bit (16 colors), ANSI 8-bit (256 colors) and 24-bit TrueColor - Color formats: color names, hex codes (#RRGGBB), RGB arrays @(R,G,B) and ANSI color numbers - Gradients across the characters of the text, between 2 or more colors - Padding to a display width (-AutoPad) that counts wide characters such as emoji and CJK as 2 cells - Styles: Bold, Italic, Underline, Blink, Faint, CrossedOut, DoubleUnderline, Overline - Reusable style profiles (PSColorStyle objects) - Indentation, centering, blank lines before and after, and timestamps - Logging to a file, with a timestamp and a level Each line goes to the host in as few Write-Host calls as the colors allow, with the line end on the last call. PowerShell records each Write-Host call as one line in a transcript, so: - Where escape codes reach the screen, a line goes out as one call: on PowerShell 7.2 and later always, and on Windows PowerShell 5.1 when the line uses styles or an ANSI mode. PowerShell 7.2 and later remove escape codes from transcripts; Windows PowerShell 5.1 keeps them. - Otherwise each color is its own Write-Host call with -ForegroundColor and -BackgroundColor, and a line of several colors takes several lines in a transcript. The terminal's color support is detected with Test-AnsiSupport when the module is imported. A color mode the terminal does not support falls back: TrueColor, then ANSI8, then ANSI4, then the console's 16 colors. NO_COLOR, FORCE_COLOR=0 or TERM=dumb turn colors and styles off, and FORCE_COLOR=1, 2 or 3 sets the color mode. .PARAMETER Text The text to write. Several strings are written on one line, each with its own colors and styles. Strings piped in are written one line each. Example: -Text 'Hello', ' ', 'World' .PARAMETER Color The foreground color of each text segment, in any of these forms: - Color names: 'Red', 'Blue', 'DarkGreen', 'Cyan', and the other names of 44 color families, most with Dark and Light variants (Get-ColorTableWithRGB lists them) - Hex codes: '#FF0000', '0xFF0000' - RGB arrays: @(255, 0, 0) with -TrueColor, or one RGB array per segment, @(@(255, 0, 0), @(0, 0, 255)) - ANSI color numbers: 0-255 with -ANSI8, the ANSI4 codes with -ANSI4, and 0-15 (System.ConsoleColor) otherwise A hex code or an RGB array is a TrueColor color. Without -TrueColor, -ANSI8 or -ANSI4, it is written in the best mode the terminal has: TrueColor, or the nearest 256-color, 16-color or console color. With fewer colors than text segments the colors repeat; extra colors are ignored. Without -Color the text takes the terminal's default color. Aliases: C, ForegroundColor, FGC Example: -Color 'Red', 'Blue' -or- -Color '#FF8000' -or- -Color @(255,128,0) .PARAMETER BackGroundColor The background color of each text segment, in the same forms as -Color. Without it the text takes the terminal's default background. Aliases: B, BGC Example: -BackGroundColor 'DarkRed' -or- -BackGroundColor '#2C2C2C' .PARAMETER Gradient Two or more colors to blend across the characters of the text. Each character takes a color interpolated between the colors given. A gradient needs ANSI 8-bit or TrueColor support and selects the best mode available. A segment with its own entry in -Color keeps that color instead of the gradient; $null in -Color leaves a segment to the gradient. Alias: Grad Example: -Gradient @('Red', 'Blue') Example: -Gradient @('Red', 'Yellow', 'Green', 'Cyan', 'Blue', 'Magenta') .PARAMETER ANSI4 Uses ANSI 4-bit colors (16 colors: 8 normal and 8 bright). Without terminal support it falls back to the console's colors. Alias: A4 .PARAMETER ANSI8 Uses ANSI 8-bit colors (256 colors: 16 standard, a 216-color cube and 24 grays). Without terminal support it falls back to ANSI4 or the console's colors. Alias: A8 .PARAMETER ANSI24 Uses 24-bit TrueColor (RGB). Three integers for one segment, @(255, 0, 0), are one RGB color only with it. Without terminal support it falls back to ANSI8 or ANSI4, with a warning. Aliases: A24, TrueColor, TC .PARAMETER Style Styles for the text segments in order: an array with one style per segment, or with an array of styles for a segment that takes several. One style alone styles the first segment, as an array of one does. -Bold and the other style switches style every segment. Valid styles: Bold, Faint, Italic, Underline, Blink, CrossedOut, DoubleUnderline, Overline Alias: S Example: -Style 'Bold' Example: -Style @('Bold', 'Italic', 'Underline') Example: -Style @(@('Bold', 'Underline'), 'Italic') .PARAMETER StyleProfile A PSColorStyle object holding colors, styles and layout settings. Parameters given on the command line take precedence over the profile. Built-in profiles: Default, Error, Warning, Info, Success, Critical, Debug New-ColorStyle creates more. Example: -StyleProfile ([PSColorStyle]::Profiles['Error']) .PARAMETER Default Applies the default style set with Set-ColorDefault. Parameters given on the command line take precedence over it. .PARAMETER Bold Writes the text in bold. Where the terminal shows bold as brighter colors rather than a bold font, the colors are made lighter instead. .PARAMETER Faint Writes the text faint (dimmed). With ANSI4 colors only bright colors dim. .PARAMETER Italic Writes the text in italics. The Windows console host (conhost.exe) does not show italics. .PARAMETER Underline Underlines the text. .PARAMETER Blink Makes the text blink, where the terminal supports it. Many terminals do not. .PARAMETER CrossedOut Strikes the text through. Alias: Strikethrough .PARAMETER DoubleUnderline Underlines the text twice, where the terminal supports it. Most terminals show a single underline. .PARAMETER Overline Draws a line above the text, where the terminal supports it. .PARAMETER StartTab The number of tab characters before the text. Default: 0 Alias: Indent .PARAMETER LinesBefore The number of blank lines before the text. Default: 0 .PARAMETER LinesAfter The number of blank lines after the text. Default: 0 .PARAMETER StartSpaces The number of spaces before the text, after any tabs from -StartTab. Default: 0 .PARAMETER AutoPad Pads the text to this display width, measured with Measure-DisplayWidth, so wide characters such as emoji and CJK count as 2 cells and combining marks as 0. Text already this wide or wider is not padded or cut. 0 turns padding off. Default: 0 Aliases: PadWidth, Pad Example: -AutoPad 20 Example: 'Server ✅' -AutoPad 21 # ✅ takes 2 cells, so 12 spaces are added .PARAMETER PadLeft With -AutoPad, pads on the left (right-aligns the text) instead of the right. Alias: RightAlign Example: -AutoPad 20 -PadLeft .PARAMETER PadChar The character -AutoPad pads with. A wide character (2 cells) gives a warning, since the padding may then fall a cell short. A zero-width character is replaced with a space. Default: ' ' (space) Aliases: PaddingChar, FillChar Example: -PadChar '.' .PARAMETER LogFile Writes the text to this log file as well. A file name alone goes in the folder from -LogPath. A path with a folder is used as given, relative to the current location, and -LogPath is not used. A name without an extension gets .log. A missing folder is created. Default: '' (no logging) Alias: L Example: -LogFile 'application.log' Example: -LogFile 'C:\Logs\app.log' .PARAMETER LogPath The folder for a -LogFile given as a file name alone. Default: the folder of the script calling Write-ColorEX, or the current location when it is called from the prompt Alias: LP Example: -LogPath 'C:\Logs' .PARAMETER LogLevel A level written in brackets before the text in the log file, such as ERROR or INFO. Default: '' (no level) Aliases: LL, LogLvl Example: -LogLevel 'ERROR' .PARAMETER LogTime Writes a timestamp in brackets before the text in the log file, in the -DateTimeFormat format. Alias: LT .PARAMETER DateTimeFormat The .NET date and time format for -LogTime and -ShowTime. Default: 'yyyy-MM-dd HH:mm:ss' Aliases: DateFormat, TimeFormat, Timestamp, TS Example: -DateTimeFormat 'yyyy-MM-dd HH:mm:ss.fff' .PARAMETER LogRetry How many times to try writing the log file, 50 ms apart, when the file is locked. Default: 2 .PARAMETER Encoding The text encoding of the log file. Each name gives the same bytes on Windows PowerShell 5.1 and PowerShell 7: - utf8, utf8NoBOM, default: UTF-8 without a byte order mark - utf8BOM: UTF-8 with a byte order mark - unicode, string, unknown: UTF-16 little-endian with a byte order mark - bigendianunicode: UTF-16 big-endian with a byte order mark - utf32, bigendianutf32: UTF-32 with a byte order mark - ascii, utf7 - ansi, oem: the system's code pages on Windows, UTF-8 elsewhere A byte order mark is written only to a new or empty file. Default: utf8 .PARAMETER ShowTime Writes the time in brackets, in dark gray, before the text on the console. .PARAMETER NoNewLine Leaves the line open, so the next output continues it. Example: Write-ColorEX 'Name: ' -NoNewLine; Write-ColorEX 'John' -Color Green .PARAMETER HorizontalCenter Centers the text in the console window, measuring the text with Measure-DisplayWidth. Nothing is centered when the window width is unknown, as with output redirected to a file. Alias: Center .PARAMETER BlankLine Writes a line of spaces as wide as the console window, which -BackGroundColor colors. With output redirected, where the window width is unknown, it writes an empty line. Aliases: BL, Empty, Blank Example: -BlankLine -BackGroundColor 'DarkBlue' .PARAMETER NoConsoleOutput Writes nothing to the host, only to the log file. Aliases: HideConsole, NoConsole, LogOnly, LO Example: -NoConsoleOutput -LogFile 'app.log' .PARAMETER Debugging Writes [DEBUG] messages about color processing and detection to the verbose stream. .PARAMETER Silent Suppresses the warnings about colors that are out of range or of the wrong form, and about fallbacks to a color mode the terminal supports. .INPUTS System.String[] Strings piped to Write-ColorEX bind to -Text, and each is written as its own line. .OUTPUTS None Write-ColorEX writes to the host and the log file, not to the pipeline. .EXAMPLE Write-ColorEX -Text 'Hello World' -Color Green Writes "Hello World" in green. .EXAMPLE Write-ColorEX -Text 'Error: ', 'File not found' -Color Red, Yellow -Bold Writes "Error: " in red and "File not found" in yellow, both bold. .EXAMPLE Write-ColorEX -Text 'Server Status' -Color '#00FF80' -TrueColor -Bold Writes "Server Status" in TrueColor green (#00FF80), bold. .EXAMPLE Write-ColorEX -Text 'RGB Color' -Color @(255, 128, 0) -TrueColor Writes "RGB Color" in orange from an RGB array. .EXAMPLE Write-ColorEX -Text 'RAINBOW' -Gradient @('Red', 'Orange', 'Yellow', 'Green', 'Cyan', 'Blue', 'Magenta') Writes "RAINBOW" with a 7-color gradient across its characters. .EXAMPLE Write-ColorEX -Text 'Test' -AutoPad 20 -Color Cyan -NoNewLine Write-Host '|' Writes "Test" padded to 20 cells in cyan, then "|" on the same line. Output: "Test |" .EXAMPLE Write-ColorEX -Text 'Server ✅' -AutoPad 21 -Color White Pads "Server ✅" to 21 cells. ✅ takes 2 cells, so the text is 9 cells and 12 spaces are added. .EXAMPLE Write-ColorEX -Text 'CPU: 45%' -AutoPad 20 -PadLeft -Color Yellow Right-aligns "CPU: 45%" in 20 cells. Output: " CPU: 45%" .EXAMPLE Write-ColorEX -Text 'Total' -AutoPad 20 -PadChar '.' -Color White Pads "Total" to 20 cells with dots. Output: "Total..............." .EXAMPLE Write-ColorEX '║ ' -Color Cyan -NoNewLine Write-ColorEX 'Web Server' -AutoPad 21 -Color White -NoNewLine Write-ColorEX ' [OK] ║' -Color Green Writes one row of a status table with box-drawing characters, aligned with -AutoPad. Output: "║ Web Server [OK] ║" .EXAMPLE Write-ColorError 'Operation failed' Writes with the built-in Error profile (red, bold). .EXAMPLE Set-ColorDefault -ForegroundColor Cyan -Bold Write-ColorEX -Text 'This uses default style' -Default Writes with the default style set by Set-ColorDefault. .EXAMPLE $style = New-ColorStyle -Name 'Header' -ForegroundColor Cyan -Bold -Underline -HorizontalCenter Write-ColorEX -Text 'SECTION HEADER' -StyleProfile $style Writes centered, bold, underlined cyan text from a custom profile. .EXAMPLE Write-ColorEX -Text 'Log Entry' -LogFile 'app.log' -LogTime -LogLevel 'INFO' Writes "Log Entry" to the host and to app.log in the calling script's folder. Log entry: "[2025-01-02 14:30:45][INFO] Log Entry" .EXAMPLE Write-ColorEX -Text 'Silent logging' -LogFile 'app.log' -NoConsoleOutput Writes to the log file only. .EXAMPLE Write-ColorEX -Text 'Title' -Color White -BackGroundColor DarkBlue -Bold -HorizontalCenter -LinesBefore 1 -LinesAfter 1 Writes centered white text on dark blue, bold, with a blank line before and after. .EXAMPLE Write-ColorEX -BlankLine -BackGroundColor DarkGray Writes a dark gray bar across the console. .EXAMPLE foreach ($file in $files) { Write-ColorEX $file.Name -AutoPad 40 -NoNewLine Write-ColorEX $file.Length -AutoPad 12 -PadLeft -Color Cyan -NoNewLine Write-ColorEX ' bytes' -Color Gray } Writes a file listing with names left-aligned and sizes right-aligned. .EXAMPLE 'first', 'second' | Write-ColorEX -Color Green Writes "first" and "second" on two lines, in green. .NOTES Name: Write-ColorEX Author: MarkusMcNugen License: MIT Requires: PowerShell 5.1 or later Windows PowerShell 5.1 reads a script without a byte order mark in the system's code page, so a script holding emoji or other non-ASCII text needs to be saved as UTF-8 with a byte order mark there. .LINK https://github.com/MarkusMcNugen/PSWriteColorEX .LINK Test-AnsiSupport .LINK New-ColorStyle .LINK Set-ColorDefault .LINK Measure-DisplayWidth #> [CmdletBinding()] [Alias('Write-ColourEX', 'Write-Color', 'Write-Colour', 'WC', 'WCEX', 'wcolor', 'wcolour')] param ( [Parameter(ValueFromPipeline = $true)] [alias ('T')][string[]] $Text, [ValidateScript({ # Strings, integers, and arrays of those or of RGB arrays if ($_ -is [string] -or $_ -is [int]) { return $true } if ($_ -is [array]) { foreach ($item in $_) { if ($item -isnot [string] -and $item -isnot [int] -and $item -isnot [array]) { return $false } } return $true } return $false })][alias ('C', 'ForegroundColor', 'FGC')][array] $Color = $null, [ValidateScript({ # Strings, integers, and arrays of those or of RGB arrays if ($_ -is [string] -or $_ -is [int]) { return $true } if ($_ -is [array]) { foreach ($item in $_) { if ($item -isnot [string] -and $item -isnot [int] -and $item -isnot [array]) { return $false } } return $true } return $false })][alias ('B', 'BGC')][array] $BackGroundColor = $null, [AllowNull()] [alias ('Grad')][object[]] $Gradient = $null, [alias ('A4')][switch] $ANSI4, [alias ('A8')][switch] $ANSI8, [alias ('A24','TrueColor','TC')][switch] $ANSI24, [ValidateScript({$_ -is [string] -or $_ -is [int] -or $_ -is [int[]] -or $_ -is [string[]] -or $_ -is [object[]]})][alias ('S')][object] $Style = $null, [PSColorStyle] $StyleProfile = $null, [switch] $Default, [switch] $Bold, [switch] $Faint, [switch] $Italic, [switch] $Underline, [switch] $Blink, [alias ('Strikethrough')][switch] $CrossedOut, [switch] $DoubleUnderline, [switch] $Overline, [alias ('Indent')][int] $StartTab = 0, [int] $LinesBefore = 0, [int] $LinesAfter = 0, [int] $StartSpaces = 0, [alias ('L')][string] $LogFile = '', [alias ('LP')][string] $LogPath = '', [alias ('LL', 'LogLvl')][string] $LogLevel = '', [alias ('LT')][switch] $LogTime, [Alias('DateFormat', 'TimeFormat', 'Timestamp', 'TS')][string] $DateTimeFormat = 'yyyy-MM-dd HH:mm:ss', [int] $LogRetry = 2, [ValidateSet('unknown', 'string', 'unicode', 'bigendianunicode', 'utf8', 'utf8BOM', 'utf8NoBOM', 'utf7', 'utf32', 'bigendianutf32', 'ascii', 'ansi', 'default', 'oem')][string]$Encoding = 'utf8', [switch] $ShowTime, [switch] $NoNewLine, [alias('Center')][switch] $HorizontalCenter, [alias ('BL', 'Empty', 'Blank')][switch] $BlankLine, [alias('HideConsole', 'NoConsole', 'LogOnly', 'LO')][switch] $NoConsoleOutput, [switch] $Debugging, [switch] $Silent, [alias('PadWidth', 'Pad')][int] $AutoPad = 0, [alias('RightAlign')][switch] $PadLeft, [alias('PaddingChar', 'FillChar')][char] $PadChar = ' ' ) begin { function Write-DebugLog { param([string]$Message) if ($Debugging) { Write-Verbose "[DEBUG] $Message" -Verbose } } # A warning that -Silent suppresses function Write-ColorWarningMsg { param([string]$Message) if (-not $Silent) { Write-Warning $Message } } # The console color an ANSI4 foreground or background code stands for function ConvertANSI4ToNativeColor { param([int]$Code) if (($Code -ge 40 -and $Code -le 47) -or ($Code -ge 100 -and $Code -le 107)) { $Code -= 10 } switch ($Code) { 30 { return 'Black' } 31 { return 'DarkRed' } 32 { return 'DarkGreen' } 33 { return 'DarkYellow' } 34 { return 'DarkBlue' } 35 { return 'DarkMagenta' } 36 { return 'DarkCyan' } 37 { return 'Gray' } 90 { return 'DarkGray' } 91 { return 'Red' } 92 { return 'Green' } 93 { return 'Yellow' } 94 { return 'Blue' } 95 { return 'Magenta' } 96 { return 'Cyan' } 97 { return 'White' } default { return 'Gray' } } } # The ANSI4 foreground code nearest an ANSI8 color code function ConvertANSI8ToANSI4 { param([int]$Code) if ($Code -lt 8) { return 30 + $Code } if ($Code -lt 16) { return 82 + $Code } if ($Code -lt 232) { # The 6x6x6 color cube $levels = 0, 95, 135, 175, 215, 255 $index = $Code - 16 $rgb = @($levels[[int][Math]::Floor($index / 36)], $levels[[int][Math]::Floor($index / 6) % 6], $levels[$index % 6]) } else { # The 24 grays $value = 8 + ($Code - 232) * 10 $rgb = @($value, $value, $value) } return Convert-RGBToANSI4 -RGB $rgb } # Console color numbers 0-15 as their names, so they keep their meaning in another color mode function ConvertConsoleColorNumber { param([object[]]$Values) $converted = [System.Collections.Generic.List[object]]::new() foreach ($value in $Values) { if ($value -is [int] -and $value -ge 0 -and $value -le 15) { $converted.Add(([System.ConsoleColor]$value).ToString()) } else { $converted.Add($value) } } return ,$converted.ToArray() } # The console color for a processed color value. An unknown name or value is Gray for # text and Black for a background. function Get-NativeColorName { param([object]$Value, [bool]$Background) $fallback = if ($Background) { 'Black' } else { 'Gray' } if ($Value -is [string]) { $entry = $Colors[$Value] if ($entry) { return $entry[0] } return $fallback } if ($Value -is [int] -and $Value -ge 0 -and $Value -le 15) { return ([System.ConsoleColor]$Value).ToString() } return $fallback } # The escape sequence that sets a processed color in the active color mode, or '' for none. # Without an ANSI mode the color is the console color as an ANSI4 code. function Get-ColorSequence { param([object]$Value, [bool]$Background) if ($null -eq $Value) { return '' } $layer = if ($Background) { 48 } else { 38 } if ($ANSI24 -and $Value -is [array] -and $Value.Count -eq 3) { return "$esc[$layer;2;$($Value[0]);$($Value[1]);$($Value[2])m" } if ($ANSI8) { if ($Value -is [string]) { $entry = $Colors[$Value] if ($entry) { return "$esc[$layer;5;$($entry[3])m" } } elseif ($Value -is [int]) { return "$esc[$layer;5;${Value}m" } return '' } if ($ANSI4) { if ($Value -is [string]) { $entry = $Colors[$Value] if ($entry) { $code = if ($Background) { $entry[2] } else { $entry[1] } return "$esc[${code}m" } } elseif ($Value -is [int]) { return "$esc[${Value}m" } return '' } # A console color name maps to itself in the color table $code = $script:ConsoleColorSgr[$Value] if ($null -eq $code -or $Value -isnot [string]) { $code = $script:ConsoleColorSgr[(Get-NativeColorName -Value $Value -Background $Background)] } if ($Background) { $code += 10 } return "$esc[${code}m" } # The styles that apply to one segment: its own from -Style, then those of the whole line function Get-StyleSequence { param([int]$Index) $parts = [System.Text.StringBuilder]::new() if ($Style -and $Style[$Index]) { if ($Style[$Index] -is [array]) { foreach ($TextStyle in $Style[$Index]) { [void]$parts.Append($ANSI[$TextStyle]) } } elseif ($Style[$Index] -is [string]) { [void]$parts.Append($ANSI[$Style[$Index]]) } } if ($Bold) { [void]$parts.Append($ANSI['Bold']) } if ($Faint) { [void]$parts.Append($ANSI['Faint']) } if ($Italic) { [void]$parts.Append($ANSI['Italic']) } if ($Underline) { [void]$parts.Append($ANSI['Underline']) } if ($Blink) { [void]$parts.Append($ANSI['Blink']) } if ($CrossedOut) { [void]$parts.Append($ANSI['CrossedOut']) } if ($DoubleUnderline) { [void]$parts.Append($ANSI['DoubleUnderline']) } if ($Overline) { [void]$parts.Append($ANSI['Overline']) } return $parts.ToString() } # The folder of the script that called Write-ColorEX, for a bare -LogFile name $callerScriptRoot = $MyInvocation.PSScriptRoot # The checks on -Color, -BackGroundColor and -Style run when the parameters are bound, and # stay on the variables, where they would reject the $null entries the function puts in # them. They are taken off the variables. $variables = $ExecutionContext.SessionState.PSVariable foreach ($name in 'Color', 'BackGroundColor', 'Style') { $variable = $variables.Get($name) foreach ($attribute in @($variable.Attributes)) { if ($attribute -is [System.Management.Automation.ValidateArgumentsAttribute]) { [void]$variable.Attributes.Remove($attribute) } } } # With piped input, the parameter values as bound, put back before each piped string so # one string's processing does not carry into the next $boundAtStart = $null if ($MyInvocation.ExpectingInput) { $boundAtStart = @{} foreach ($name in $MyInvocation.MyCommand.Parameters.Keys) { if ($name -eq 'Text') { continue } $variable = $variables.Get($name) if ($variable) { $boundAtStart[$name] = $variable.Value } } } } process { if ($null -ne $boundAtStart) { foreach ($name in $boundAtStart.Keys) { $variables.Set($name, $boundAtStart[$name]) } } Write-DebugLog "Starting Write-ColorEX with Text count: $($Text.Count)" If ($Gradient -and $Gradient.Count -lt 2) { Write-ColorWarningMsg "Gradient requires at least 2 colors (received $($Gradient.Count)). Gradient disabled." Write-DebugLog "Gradient validation failed: Only $($Gradient.Count) color(s) provided" $Gradient = $null } # Only one color mode applies: TrueColor, then ANSI8, then ANSI4 $colorModeCount = 0 if ($ANSI4) { $colorModeCount++ } if ($ANSI8) { $colorModeCount++ } if ($ANSI24) { $colorModeCount++ } if ($colorModeCount -gt 1) { Write-Warning "Multiple color modes specified. Only one of -ANSI4, -ANSI8, or -TrueColor should be used." if ($ANSI24) { Write-DebugLog "Using TrueColor mode (highest priority)" $ANSI4 = $False $ANSI8 = $False } elseif ($ANSI8) { Write-DebugLog "Using ANSI8 mode" $ANSI4 = $False $ANSI24 = $False } else { Write-DebugLog "Using ANSI4 mode" $ANSI8 = $False $ANSI24 = $False } } if ($StyleProfile) { Write-DebugLog "Applying style profile: $($StyleProfile.Name)" $profileParams = $StyleProfile.ToWriteColorParams() foreach ($key in $profileParams.Keys) { if (-not $PSBoundParameters.ContainsKey($key)) { Set-Variable -Name $key -Value $profileParams[$key] } } } if ($Default -and [PSColorStyle]::Default) { Write-DebugLog "Applying default style profile" $defaultParams = [PSColorStyle]::Default.ToWriteColorParams() foreach ($key in $defaultParams.Keys) { if (-not $PSBoundParameters.ContainsKey($key) -and -not $StyleProfile) { Set-Variable -Name $key -Value $defaultParams[$key] } } } # One style alone is the first segment's, as an array of one; indexing the string itself # would read its letters if ($Style -is [string]) { $Style = @($Style) } # A hex code or an RGB array asks for TrueColor. Without a color mode given, it takes the # best mode the terminal has, without the warnings an explicit -TrueColor gives. $impliedTrueColor = $false if (-not ($ANSI4 -or $ANSI8 -or $ANSI24)) { foreach ($value in @($Color) + @($BackGroundColor)) { if (($value -is [string] -and $value -match '^#|^0x') -or $value -is [array]) { $impliedTrueColor = $true break } } if ($impliedTrueColor) { Write-DebugLog "Hex or RGB color without a color mode: using TrueColor" $ANSI24 = $true $Color = ConvertConsoleColorNumber -Values $Color $BackGroundColor = ConvertConsoleColorNumber -Values $BackGroundColor } } # The color mode asked for, before any fallback, which the color checks read $OriginalTrueColor = [bool]$ANSI24 $OriginalANSI8 = [bool]$ANSI8 $OriginalANSI4 = [bool]$ANSI4 # Three integers for one segment under -TrueColor are one RGB color, not three colors if ($OriginalTrueColor -and $Color -and $Color.Count -eq 3 -and $Color[0] -is [int] -and $Color[1] -is [int] -and $Color[2] -is [int] -and $Text.Count -eq 1) { Write-DebugLog "Detected flattened RGB array, wrapping: @($($Color[0]),$($Color[1]),$($Color[2]))" $Color = ,@($Color[0], $Color[1], $Color[2]) } if ($OriginalTrueColor -and $BackGroundColor -and $BackGroundColor.Count -eq 3 -and $BackGroundColor[0] -is [int] -and $BackGroundColor[1] -is [int] -and $BackGroundColor[2] -is [int] -and $Text.Count -eq 1) { Write-DebugLog "Detected flattened RGB array for background, wrapping: @($($BackGroundColor[0]),$($BackGroundColor[1]),$($BackGroundColor[2]))" $BackGroundColor = ,@($BackGroundColor[0], $BackGroundColor[1], $BackGroundColor[2]) } # Padding to a display width, measured so wide characters count as 2 cells if ($AutoPad -gt 0) { Write-DebugLog "AutoPad processing: Target width = $AutoPad, PadLeft = $PadLeft, PadChar = '$PadChar'" $padCharWidth = Measure-DisplayWidth -Text $PadChar.ToString() if ($padCharWidth -eq 0) { Write-ColorWarningMsg "PadChar '$PadChar' is a zero-width character and cannot be used for padding. Using space instead." $PadChar = ' ' $padCharWidth = 1 } if ($padCharWidth -gt 1) { Write-ColorWarningMsg "PadChar '$PadChar' is a wide character ($padCharWidth cells). Padding alignment may be off." } $currentWidth = Measure-DisplayWidth -Text ($Text -join '') Write-DebugLog "Current text display width: $currentWidth cells" if ($currentWidth -lt $AutoPad) { $paddingCellsNeeded = $AutoPad - $currentWidth if ($padCharWidth -gt 1) { $padCount = [Math]::Floor($paddingCellsNeeded / $padCharWidth) $remainder = $paddingCellsNeeded % $padCharWidth if ($remainder -ne 0) { Write-DebugLog "Padding width ($paddingCellsNeeded cells) not evenly divisible by PadChar width ($padCharWidth cells). Off by $remainder cell(s)." } } else { $padCount = $paddingCellsNeeded } if ($padCount -gt 0) { $paddingString = $PadChar.ToString() * $padCount Write-DebugLog "Adding $padCount '$PadChar' character(s) = $($padCount * $padCharWidth) cells" if ($PadLeft) { $Text = @($paddingString) + $Text Write-DebugLog "Applied left padding (right-aligned text)" } else { $Text = $Text + @($paddingString) Write-DebugLog "Applied right padding (left-aligned text)" } } } else { Write-DebugLog "Text width ($currentWidth) >= Target width ($AutoPad). No padding applied." } } # NO_COLOR, FORCE_COLOR=0 and TERM=dumb turn colors and styles off; FORCE_COLOR 1 to 3 keeps them on $forcedColor = $env:FORCE_COLOR -in @('1', '2', '3') $ColorDisabled = (-not $forcedColor) -and ( ($env:FORCE_COLOR -eq '0') -or (-not [string]::IsNullOrEmpty($env:NO_COLOR)) -or ($env:TERM -eq 'dumb')) $UsingANSIFeatures = $ANSI4 -or $ANSI8 -or $ANSI24 -or $Bold -or $Italic -or $Underline -or $Blink -or $Faint -or $CrossedOut -or $DoubleUnderline -or $Overline -or $Style -or $Gradient $ComposeLine = $false If ($ColorDisabled) { Write-DebugLog "Colors are off: NO_COLOR, FORCE_COLOR=0 or TERM=dumb" $ANSISupport = $False $ANSIColorSupport = 'None' $Style = @() $Gradient = $null $ANSI4 = $False $ANSI8 = $False $ANSI24 = $False } ElseIf (-not $UsingANSIFeatures) { # Console colors only: no ANSI detection needed, only whether the line can go out as one call $ANSISupport = $False $ANSIColorSupport = 'None' $ComposeLine = (-not $NoConsoleOutput) -and (Test-ColorLineComposition) Write-DebugLog "Console colors only; one call per line: $ComposeLine" } Else { # FORCE_COLOR can change at any time, so it is read here rather than cached if ($forcedColor) { Write-DebugLog "FORCE_COLOR environment variable detected: $($env:FORCE_COLOR)" switch ($env:FORCE_COLOR) { '1' { $ANSIColorSupport = 'ANSI4' } '2' { $ANSIColorSupport = 'ANSI8' } '3' { $ANSIColorSupport = 'TrueColor' } } Write-DebugLog "FORCE_COLOR override: $ANSIColorSupport" } else { if ($null -eq $script:CachedANSISupport) { $script:CachedANSISupport = (Test-AnsiSupport -Silent).ColorSupport } $ANSIColorSupport = $script:CachedANSISupport if ($ANSIColorSupport -ne 'None' -and -not (Test-ColorHostAnsi)) { # PowerShell would remove the escape codes before they reach the screen Write-DebugLog "The host renders no escape codes; using console colors" $ANSIColorSupport = 'None' } Write-DebugLog "ANSI Color Support: $ANSIColorSupport (cached)" } $ANSISupport = $ANSIColorSupport -ne 'None' If ($ANSIColorSupport -eq 'None') { $Style = @() $ANSI4 = $False $ANSI8 = $False $ANSI24 = $False Write-DebugLog "ANSI support disabled - using native PowerShell colors" } ElseIf ($ANSI24 -and $ANSIColorSupport -ne 'TrueColor') { if ($ANSIColorSupport -eq 'ANSI8') { if (-not $impliedTrueColor) { Write-ColorWarningMsg "TrueColor not supported by terminal. Falling back to ANSI8 (256 colors)." } Write-DebugLog "Downgrading from TrueColor to ANSI8" $ANSI24 = $False $ANSI8 = $True } else { if (-not $impliedTrueColor) { Write-ColorWarningMsg "TrueColor not supported by terminal. Falling back to ANSI4 (16 colors)." } Write-DebugLog "Downgrading from TrueColor to ANSI4" $ANSI24 = $False $ANSI4 = $True } } ElseIf ($ANSI8 -and $ANSIColorSupport -eq 'ANSI4') { Write-ColorWarningMsg "ANSI8 (256 colors) not supported by terminal. Falling back to ANSI4 (16 colors)." Write-DebugLog "Downgrading from ANSI8 to ANSI4" $ANSI8 = $False $ANSI4 = $True } If ($Gradient -and $Gradient.Count -ge 2) { Write-DebugLog "Gradient requested with $($Gradient.Count) colors" If ($ANSIColorSupport -eq 'None') { Write-ColorWarningMsg "Gradient requires ANSI 256-color or TrueColor support. Terminal supports: None. Gradient disabled." Write-DebugLog "Gradient disabled: No ANSI support" $Gradient = $null } ElseIf ($ANSIColorSupport -eq 'ANSI4') { Write-ColorWarningMsg "Gradient requires ANSI 256-color or TrueColor support. Terminal supports: ANSI4 (16 colors). Gradient disabled." Write-DebugLog "Gradient disabled: ANSI4 only" $Gradient = $null } Else { If (-not $ANSI8 -and -not $ANSI24) { If ($ANSIColorSupport -eq 'TrueColor') { Write-DebugLog "Gradient: Auto-enabling TrueColor mode" $ANSI24 = $True } Else { Write-DebugLog "Gradient: Auto-enabling ANSI8 mode" $ANSI8 = $True } } Write-DebugLog "Gradient enabled in $ANSIColorSupport mode" } } } If (-not $NoConsoleOutput) { $esc = [char]27 $ANSI = @{ 'Reset' = "$esc[0m" 'Bold' = "$esc[1m" 'Faint' = "$esc[2m" 'Italic' = "$esc[3m" 'Underline' = "$esc[4m" 'Blink' = "$esc[5m" 'CrossedOut' = "$esc[9m" 'DoubleUnderline' = "$esc[21m" 'Overline' = "$esc[53m" 'None' = "" } if ($null -eq $script:CachedColorTable) { $script:CachedColorTable = Get-ColorTableWithRGB } $Colors = $script:CachedColorTable $WindowWidth = 0 If ($BlankLine -or $HorizontalCenter) { $WindowWidth = Get-ColorHostWidth } If ($BlankLine) { Write-DebugLog "Processing blank line" $HorizontalCenter = $False $StartTab = 0 $StartSpaces = 0 $ShowTime = $False $Text = [string[]]@(' ' * $WindowWidth) } $gradientArray = $null If ($Gradient -and $Gradient.Count -ge 2) { Write-DebugLog "Calculating gradient for text" # Each segment split into the characters a terminal draws, so no color code lands # inside an emoji or between a letter and its accent $gradientCharacters = [System.Collections.Generic.List[object]]::new() $totalChars = 0 foreach ($segment in $Text) { $characters = @(Split-DisplayCharacter -Text $segment) $gradientCharacters.Add($characters) $totalChars += $characters.Count } Write-DebugLog "Total characters for gradient: $totalChars" if ($Gradient.Count -gt $totalChars) { Write-ColorWarningMsg "Gradient has $($Gradient.Count) colors but text only has $totalChars characters. Applying standard coloring instead." Write-DebugLog "Gradient disabled: More colors ($($Gradient.Count)) than characters ($totalChars)" $Gradient = $null } else { $gradientMode = if ($ANSI24) { 'TrueColor' } else { 'ANSI8' } Write-DebugLog "Generating gradient in $gradientMode mode" $gradientArray = New-GradientColorArray -Colors $Gradient -Steps $totalChars -Mode $gradientMode if (-not $gradientArray) { Write-DebugLog "Gradient array generation failed" $Gradient = $null } } } # Each segment's foreground color, cycling through -Color, converted for the active mode If ($Color.Count -gt 0 -and -not $ColorDisabled) { Write-DebugLog "Processing $($Color.Count) colors" $ProcessedColors = [System.Collections.Generic.List[object]]::new() For ($i = 0; $i -lt $Text.Length; $i++) { $colorIndex = $i % $Color.Count $currentColor = $Color[$colorIndex] if ($null -eq $currentColor) { # $null leaves the segment to a gradient, or to the terminal's color $null = $ProcessedColors.Add($null) continue } Write-DebugLog "Processing color at index $($i): $currentColor (type: $($currentColor.GetType().Name))" # Checked against the mode asked for, before any fallback if ($OriginalTrueColor) { if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $r = [Math]::Max(0, [Math]::Min(255, [int]$currentColor[0])) $g = [Math]::Max(0, [Math]::Min(255, [int]$currentColor[1])) $b = [Math]::Max(0, [Math]::Min(255, [int]$currentColor[2])) if ($r -ne $currentColor[0] -or $g -ne $currentColor[1] -or $b -ne $currentColor[2]) { Write-ColorWarningMsg "RGB values out of range (0-255). Original: @($($currentColor[0]),$($currentColor[1]),$($currentColor[2])). Clamped to: @($r,$g,$b)" $currentColor = @($r, $g, $b) } } elseif ($currentColor -is [int] -and -not $impliedTrueColor) { Write-ColorWarningMsg "TrueColor mode expects RGB array @(R,G,B) or hex color, but received integer code $currentColor. Use -ANSI8 or -ANSI4 for integer codes." Write-DebugLog "Type mismatch: integer $currentColor provided for TrueColor" } } elseif ($OriginalANSI8) { if ($currentColor -is [array] -and $currentColor.Count -eq 3) { Write-ColorWarningMsg "ANSI8 mode expects integer code (0-255) or color name, but received RGB array. Use -TrueColor for RGB arrays." Write-DebugLog "Type mismatch: RGB array provided for ANSI8" } elseif ($currentColor -is [int]) { if ($currentColor -lt 0 -or $currentColor -gt 255) { Write-ColorWarningMsg "ANSI8 color code $currentColor is out of range (0-255). Using Gray (7)." $currentColor = 7 } } } # Where the terminal shows bold as brighter colors, the color is made lighter instead if ($Bold -and -not $script:SupportsBoldFonts) { Write-DebugLog "Bold enabled but terminal doesn't support bold fonts - auto-lightening color" if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $currentColor = Get-LighterRGBColor -RGB $currentColor Write-DebugLog "RGB color lightened to: R=$($currentColor[0]) G=$($currentColor[1]) B=$($currentColor[2])" } elseif ($currentColor -is [int]) { # ANSI4 codes are left to the terminal, which brightens them for bold if ($ANSI8 -and $currentColor -ge 0 -and $currentColor -le 255) { $originalCode = $currentColor $currentColor = Get-LighterANSI8Color -ANSI8Code $currentColor Write-DebugLog "ANSI8 code $originalCode algorithmically lightened to $currentColor" } } elseif ($currentColor -is [string] -and $currentColor -notmatch '^#|^0x') { $lightenedName = Get-LighterColorName -ColorName $currentColor if ($lightenedName -ne $currentColor) { $currentColor = $lightenedName Write-DebugLog "Color name lightened from $($Color[$colorIndex]) to $currentColor" } else { # No lighter name: ANSI8 and TrueColor lighten the color's value instead if ($ANSI8 -and $Colors.ContainsKey($currentColor)) { $ansi8Code = $Colors[$currentColor][3] $lightenedCode = Get-LighterANSI8Color -ANSI8Code $ansi8Code $currentColor = $lightenedCode Write-DebugLog "Color name $($Color[$colorIndex]) algorithmically lightened in ANSI8 from code $ansi8Code to $lightenedCode" } elseif ($ANSI24 -and $Colors.ContainsKey($currentColor)) { $rgb = $Colors[$currentColor][4] $lightenedRGB = Get-LighterRGBColor -RGB $rgb $currentColor = $lightenedRGB Write-DebugLog "Color name $($Color[$colorIndex]) algorithmically lightened in ANSI24 from RGB to R=$($lightenedRGB[0]) G=$($lightenedRGB[1]) B=$($lightenedRGB[2])" } } } elseif ($currentColor -is [string] -and $currentColor -match '^#|^0x') { $rgb = Convert-HexToRGB -Hex $currentColor $currentColor = Get-LighterRGBColor -RGB $rgb Write-DebugLog "Hex color $($Color[$colorIndex]) converted to RGB and lightened" } } # Converted for the mode in use after any fallback if ($ANSI24) { if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $null = $ProcessedColors.Add($currentColor) Write-DebugLog "RGB array color: R=$($currentColor[0]) G=$($currentColor[1]) B=$($currentColor[2])" } elseif ($currentColor -is [string] -and $currentColor -match '^#|^0x') { $rgb = Convert-HexToRGB -Hex $currentColor $null = $ProcessedColors.Add($rgb) Write-DebugLog "Hex color $currentColor converted to RGB: R=$($rgb[0]) G=$($rgb[1]) B=$($rgb[2])" } elseif ($currentColor -is [string]) { $colorEntry = $Colors[$currentColor] if ($colorEntry) { $null = $ProcessedColors.Add($colorEntry[4]) Write-DebugLog "Named color $currentColor mapped to RGB" } else { $null = $ProcessedColors.Add($currentColor) } } else { $null = $ProcessedColors.Add($currentColor) } } elseif ($ANSI8 -and $OriginalTrueColor) { # TrueColor fell back to ANSI8 if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $ansi8Code = Convert-RGBToANSI8 -RGB $currentColor $null = $ProcessedColors.Add($ansi8Code) Write-DebugLog "RGB @($($currentColor[0]),$($currentColor[1]),$($currentColor[2])) converted to ANSI8: $ansi8Code" } elseif ($currentColor -is [string] -and $currentColor -match '^#|^0x') { $ansi8Code = Convert-RGBToANSI8 -RGB (Convert-HexToRGB -Hex $currentColor) $null = $ProcessedColors.Add($ansi8Code) Write-DebugLog "Hex $currentColor converted to ANSI8: $ansi8Code" } else { $null = $ProcessedColors.Add($currentColor) } } elseif ($ANSI4 -and $OriginalTrueColor) { # TrueColor fell back to ANSI4 if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $ansi4Code = Convert-RGBToANSI4 -RGB $currentColor $null = $ProcessedColors.Add($ansi4Code) Write-DebugLog "RGB @($($currentColor[0]),$($currentColor[1]),$($currentColor[2])) converted to ANSI4: $ansi4Code" } elseif ($currentColor -is [string] -and $currentColor -match '^#|^0x') { $ansi4Code = Convert-RGBToANSI4 -RGB (Convert-HexToRGB -Hex $currentColor) $null = $ProcessedColors.Add($ansi4Code) Write-DebugLog "Hex $currentColor converted to ANSI4: $ansi4Code" } else { $null = $ProcessedColors.Add($currentColor) } } elseif (-not $ANSISupport -and $OriginalTrueColor) { # TrueColor fell back to console colors, through the nearest ANSI4 code if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $nativeColor = ConvertANSI4ToNativeColor -Code (Convert-RGBToANSI4 -RGB $currentColor) $null = $ProcessedColors.Add($nativeColor) Write-DebugLog "RGB @($($currentColor[0]),$($currentColor[1]),$($currentColor[2])) converted to Native: $nativeColor" } elseif ($currentColor -is [string] -and $currentColor -match '^#|^0x') { $nativeColor = ConvertANSI4ToNativeColor -Code (Convert-RGBToANSI4 -RGB (Convert-HexToRGB -Hex $currentColor)) $null = $ProcessedColors.Add($nativeColor) Write-DebugLog "Hex $currentColor converted to Native: $nativeColor" } else { $null = $ProcessedColors.Add($currentColor) } } elseif ($currentColor -is [int] -and $ANSI4 -and $OriginalANSI8) { # ANSI8 fell back to ANSI4 $ansi4Code = ConvertANSI8ToANSI4 -Code $currentColor $null = $ProcessedColors.Add($ansi4Code) Write-DebugLog "ANSI8 code $currentColor converted to ANSI4: $ansi4Code" } elseif ($currentColor -is [int] -and -not $ANSISupport -and ($OriginalANSI8 -or $OriginalANSI4)) { # ANSI8 or ANSI4 fell back to console colors $ansi4Code = if ($OriginalANSI8) { ConvertANSI8ToANSI4 -Code $currentColor } else { $currentColor } $nativeColor = ConvertANSI4ToNativeColor -Code $ansi4Code $null = $ProcessedColors.Add($nativeColor) Write-DebugLog "Color code $currentColor converted to Native: $nativeColor" } else { $null = $ProcessedColors.Add($currentColor) } } $Color = $ProcessedColors.ToArray() } Else { $Color = @() } # Each segment's background color, cycling through -BackGroundColor, converted for the active mode If ($BackGroundColor.Count -gt 0 -and -not $ColorDisabled) { Write-DebugLog "Processing $($BackGroundColor.Count) background colors" $ProcessedBGColors = [System.Collections.Generic.List[object]]::new() For ($i = 0; $i -lt $Text.Length; $i++) { $colorIndex = $i % $BackGroundColor.Count $currentColor = $BackGroundColor[$colorIndex] if ($null -eq $currentColor -or $currentColor -eq "None") { $null = $ProcessedBGColors.Add($null) continue } # Checked against the mode asked for, before any fallback if ($OriginalTrueColor) { if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $r = [Math]::Max(0, [Math]::Min(255, [int]$currentColor[0])) $g = [Math]::Max(0, [Math]::Min(255, [int]$currentColor[1])) $b = [Math]::Max(0, [Math]::Min(255, [int]$currentColor[2])) if ($r -ne $currentColor[0] -or $g -ne $currentColor[1] -or $b -ne $currentColor[2]) { Write-ColorWarningMsg "Background RGB values out of range (0-255). Original: @($($currentColor[0]),$($currentColor[1]),$($currentColor[2])). Clamped to: @($r,$g,$b)" $currentColor = @($r, $g, $b) } } elseif ($currentColor -is [int] -and -not $impliedTrueColor) { Write-ColorWarningMsg "TrueColor mode expects RGB array @(R,G,B) or hex color for background, but received integer code $currentColor. Use -ANSI8 or -ANSI4 for integer codes." Write-DebugLog "Type mismatch: integer $currentColor provided for TrueColor background" } } elseif ($OriginalANSI8) { if ($currentColor -is [array] -and $currentColor.Count -eq 3) { Write-ColorWarningMsg "ANSI8 mode expects integer code (0-255) or color name for background, but received RGB array. Use -TrueColor for RGB arrays." Write-DebugLog "Type mismatch: RGB array provided for ANSI8 background" } elseif ($currentColor -is [int]) { if ($currentColor -lt 0 -or $currentColor -gt 255) { Write-ColorWarningMsg "Background ANSI8 color code $currentColor is out of range (0-255). Using Gray (7)." $currentColor = 7 } } } # Where the terminal shows bold as brighter colors, the color is made lighter instead if ($Bold -and -not $script:SupportsBoldFonts) { Write-DebugLog "Bold enabled but terminal doesn't support bold fonts - auto-lightening background color" if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $currentColor = Get-LighterRGBColor -RGB $currentColor Write-DebugLog "Background RGB color lightened to: R=$($currentColor[0]) G=$($currentColor[1]) B=$($currentColor[2])" } elseif ($currentColor -is [int]) { # ANSI4 codes are left to the terminal, which brightens them for bold if ($ANSI8 -and $currentColor -ge 0 -and $currentColor -le 255) { $originalCode = $currentColor $currentColor = Get-LighterANSI8Color -ANSI8Code $currentColor Write-DebugLog "Background ANSI8 code $originalCode algorithmically lightened to $currentColor" } } elseif ($currentColor -is [string] -and $currentColor -notmatch '^#|^0x') { $lightenedName = Get-LighterColorName -ColorName $currentColor if ($lightenedName -ne $currentColor) { $currentColor = $lightenedName Write-DebugLog "Background color name lightened from $($BackGroundColor[$colorIndex]) to $currentColor" } else { # No lighter name: ANSI8 and TrueColor lighten the color's value instead if ($ANSI8 -and $Colors.ContainsKey($currentColor)) { $ansi8Code = $Colors[$currentColor][3] $lightenedCode = Get-LighterANSI8Color -ANSI8Code $ansi8Code $currentColor = $lightenedCode Write-DebugLog "Background color name $($BackGroundColor[$colorIndex]) algorithmically lightened in ANSI8 from code $ansi8Code to $lightenedCode" } elseif ($ANSI24 -and $Colors.ContainsKey($currentColor)) { $rgb = $Colors[$currentColor][4] $lightenedRGB = Get-LighterRGBColor -RGB $rgb $currentColor = $lightenedRGB Write-DebugLog "Background color name $($BackGroundColor[$colorIndex]) algorithmically lightened in ANSI24 from RGB to R=$($lightenedRGB[0]) G=$($lightenedRGB[1]) B=$($lightenedRGB[2])" } } } elseif ($currentColor -is [string] -and $currentColor -match '^#|^0x') { $rgb = Convert-HexToRGB -Hex $currentColor $currentColor = Get-LighterRGBColor -RGB $rgb Write-DebugLog "Background hex color $($BackGroundColor[$colorIndex]) converted to RGB and lightened" } } # Converted for the mode in use after any fallback if ($ANSI24) { if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $null = $ProcessedBGColors.Add($currentColor) } elseif ($currentColor -is [string] -and $currentColor -match '^#|^0x') { $null = $ProcessedBGColors.Add((Convert-HexToRGB -Hex $currentColor)) } elseif ($currentColor -is [string]) { $colorEntry = $Colors[$currentColor] if ($colorEntry) { $null = $ProcessedBGColors.Add($colorEntry[4]) } else { $null = $ProcessedBGColors.Add($currentColor) } } else { $null = $ProcessedBGColors.Add($currentColor) } } elseif ($ANSI8 -and $OriginalTrueColor) { # TrueColor fell back to ANSI8 if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $ansi8Code = Convert-RGBToANSI8 -RGB $currentColor $null = $ProcessedBGColors.Add($ansi8Code) Write-DebugLog "Background RGB @($($currentColor[0]),$($currentColor[1]),$($currentColor[2])) converted to ANSI8: $ansi8Code" } elseif ($currentColor -is [string] -and $currentColor -match '^#|^0x') { $ansi8Code = Convert-RGBToANSI8 -RGB (Convert-HexToRGB -Hex $currentColor) $null = $ProcessedBGColors.Add($ansi8Code) Write-DebugLog "Background hex $currentColor converted to ANSI8: $ansi8Code" } else { $null = $ProcessedBGColors.Add($currentColor) } } elseif ($ANSI4 -and $OriginalTrueColor) { # TrueColor fell back to ANSI4; a background code is the foreground code plus 10 if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $ansi4Code = (Convert-RGBToANSI4 -RGB $currentColor) + 10 $null = $ProcessedBGColors.Add($ansi4Code) Write-DebugLog "Background RGB @($($currentColor[0]),$($currentColor[1]),$($currentColor[2])) converted to ANSI4: $ansi4Code" } elseif ($currentColor -is [string] -and $currentColor -match '^#|^0x') { $ansi4Code = (Convert-RGBToANSI4 -RGB (Convert-HexToRGB -Hex $currentColor)) + 10 $null = $ProcessedBGColors.Add($ansi4Code) Write-DebugLog "Background hex $currentColor converted to ANSI4: $ansi4Code" } else { $null = $ProcessedBGColors.Add($currentColor) } } elseif (-not $ANSISupport -and $OriginalTrueColor) { # TrueColor fell back to console colors, through the nearest ANSI4 code if ($currentColor -is [array] -and $currentColor.Count -eq 3) { $null = $ProcessedBGColors.Add((ConvertANSI4ToNativeColor -Code (Convert-RGBToANSI4 -RGB $currentColor))) } elseif ($currentColor -is [string] -and $currentColor -match '^#|^0x') { $null = $ProcessedBGColors.Add((ConvertANSI4ToNativeColor -Code (Convert-RGBToANSI4 -RGB (Convert-HexToRGB -Hex $currentColor)))) } else { $null = $ProcessedBGColors.Add($currentColor) } } elseif ($currentColor -is [int] -and $ANSI4 -and $OriginalANSI8) { # ANSI8 fell back to ANSI4; a background code is the foreground code plus 10 $ansi4Code = (ConvertANSI8ToANSI4 -Code $currentColor) + 10 $null = $ProcessedBGColors.Add($ansi4Code) Write-DebugLog "Background ANSI8 code $currentColor converted to ANSI4: $ansi4Code" } elseif ($currentColor -is [int] -and -not $ANSISupport -and ($OriginalANSI8 -or $OriginalANSI4)) { # ANSI8 or ANSI4 fell back to console colors $ansi4Code = if ($OriginalANSI8) { ConvertANSI8ToANSI4 -Code $currentColor } else { $currentColor } $null = $ProcessedBGColors.Add((ConvertANSI4ToNativeColor -Code $ansi4Code)) } else { $null = $ProcessedBGColors.Add($currentColor) } } $BackGroundColor = $ProcessedBGColors.ToArray() } Else { $BackGroundColor = @() } Write-DebugLog "Starting text output" # What comes before the text: centering, tabs and spaces, then the time $prefix = '' If ($HorizontalCenter -and $WindowWidth -gt 0) { $MessageLength = Measure-DisplayWidth -Text ($Text -join '') If ($WindowWidth -ge $MessageLength) { $CenterPosition = [int][Math]::Max(0, $WindowWidth / 2 - [Math]::Floor($MessageLength / 2)) $prefix += ' ' * $CenterPosition } } If ($StartTab -gt 0) { $prefix += "`t" * $StartTab } If ($StartSpaces -gt 0) { $prefix += ' ' * $StartSpaces } $timeText = '' If ($ShowTime) { $timeText = "[$([datetime]::Now.ToString($DateTimeFormat))] " } For ($i = 0; $i -lt $LinesBefore; $i++) { Write-Host '' } If ($ColorDisabled) { # One call, no colors or styles $line = $prefix + $timeText + ($Text -join '') If ($line.Length -gt 0 -or -not $NoNewLine) { Write-Host -Object $line -NoNewline:$NoNewLine } } ElseIf ($ANSISupport -or $ComposeLine) { # One call, the colors and styles as escape codes $builder = [System.Text.StringBuilder]::new() [void]$builder.Append($prefix) If ($timeText) { [void]$builder.Append("$esc[90m$timeText$($ANSI['Reset'])") } If ($gradientArray) { Write-DebugLog "Using gradient mode for output" $charIndex = 0 For ($segmentIdx = 0; $segmentIdx -lt $Text.Length; $segmentIdx++) { $segment = $Text[$segmentIdx] $explicitColor = $null if ($segmentIdx -lt $Color.Count) { $explicitColor = $Color[$segmentIdx] } if ($null -ne $explicitColor) { # A segment with its own color keeps it instead of the gradient Write-DebugLog "Segment $segmentIdx has explicit color override (skipping gradient)" [void]$builder.Append((Get-StyleSequence -Index $segmentIdx)) [void]$builder.Append((Get-ColorSequence -Value $explicitColor -Background $false)) [void]$builder.Append($segment) [void]$builder.Append($ANSI['Reset']) $charIndex += $gradientCharacters[$segmentIdx].Count } else { [void]$builder.Append((Get-StyleSequence -Index $segmentIdx)) foreach ($char in $gradientCharacters[$segmentIdx]) { $gradientColor = $gradientArray[$charIndex] If ($ANSI24 -and $gradientColor -is [array] -and $gradientColor.Count -eq 3) { [void]$builder.Append("$esc[38;2;$($gradientColor[0]);$($gradientColor[1]);$($gradientColor[2])m") } ElseIf ($ANSI8 -and $gradientColor -is [int]) { [void]$builder.Append("$esc[38;5;${gradientColor}m") } [void]$builder.Append($char) $charIndex++ } [void]$builder.Append($ANSI['Reset']) } } } Else { For ($i = 0; $i -lt $Text.Length; $i++) { $codes = '' If ($ANSISupport) { $codes = Get-StyleSequence -Index $i } If ($i -lt $Color.Count) { $codes += Get-ColorSequence -Value $Color[$i] -Background $false } If ($i -lt $BackGroundColor.Count) { $codes += Get-ColorSequence -Value $BackGroundColor[$i] -Background $true } [void]$builder.Append($codes) [void]$builder.Append($Text[$i]) If ($codes.Length -gt 0) { [void]$builder.Append($ANSI['Reset']) } } } $line = $builder.ToString() If ($line.Length -gt 0 -or -not $NoNewLine) { Write-Host -Object $line -NoNewline:$NoNewLine } } Else { # One call per color, each with -ForegroundColor and -BackgroundColor $pieces = [System.Collections.Generic.List[object]]::new() If ($prefix) { $pieces.Add(@{ Text = $prefix; Fg = $null; Bg = $null }) } If ($timeText) { $pieces.Add(@{ Text = $timeText; Fg = 'DarkGray'; Bg = $null }) } For ($i = 0; $i -lt $Text.Length; $i++) { $fg = $null $bg = $null If ($i -lt $Color.Count -and $null -ne $Color[$i]) { $fg = Get-NativeColorName -Value $Color[$i] -Background $false } If ($i -lt $BackGroundColor.Count -and $null -ne $BackGroundColor[$i]) { $bg = Get-NativeColorName -Value $BackGroundColor[$i] -Background $true } $pieces.Add(@{ Text = [string]$Text[$i]; Fg = $fg; Bg = $bg }) } # White space with no background shows no foreground color, so it joins a # neighbor with no background; pieces of one color pair go out together $merged = [System.Collections.Generic.List[object]]::new() foreach ($piece in $pieces) { if ($piece.Text.Length -eq 0) { continue } $previousPiece = if ($merged.Count -gt 0) { $merged[$merged.Count - 1] } else { $null } $blank = [string]::IsNullOrWhiteSpace($piece.Text) -and $null -eq $piece.Bg if ($null -ne $previousPiece -and $null -eq $previousPiece.Bg -and $blank) { $previousPiece.Text += $piece.Text continue } if ($null -ne $previousPiece -and $previousPiece.Fg -eq $piece.Fg -and $previousPiece.Bg -eq $piece.Bg) { $previousPiece.Text += $piece.Text continue } if ($null -ne $previousPiece -and $null -eq $piece.Bg -and $null -eq $previousPiece.Bg -and [string]::IsNullOrWhiteSpace($previousPiece.Text)) { $piece.Text = $previousPiece.Text + $piece.Text $merged[$merged.Count - 1] = $piece continue } $merged.Add($piece) } If ($merged.Count -eq 0) { If (-not $NoNewLine) { Write-Host '' } } Else { $lastPiece = $merged.Count - 1 For ($p = 0; $p -le $lastPiece; $p++) { $piece = $merged[$p] $hostParameters = @{ Object = $piece.Text NoNewline = ($p -lt $lastPiece) -or $NoNewLine } if ($piece.Fg) { $hostParameters['ForegroundColor'] = $piece.Fg } if ($piece.Bg) { $hostParameters['BackgroundColor'] = $piece.Bg } Write-Host @hostParameters } } } For ($i = 0; $i -lt $LinesAfter; $i++) { Write-Host '' } } If ($Text.Count -and $LogFile) { Write-DebugLog "Writing to log file: $LogFile" $logName = $LogFile If ($logName -notmatch '[\\/]') { if ($logName -notmatch '\.\w+$') { $logName += '.log' } $folder = $LogPath If ([string]::IsNullOrEmpty($folder)) { $folder = Resolve-ColorLogFolder -ScriptRoot $callerScriptRoot } $logName = Join-Path -Path $folder -ChildPath $logName } $LogFilePath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($logName) $LogInfo = '' If ($LogTime) { $LogInfo += "[$([datetime]::Now.ToString($DateTimeFormat))]" } If ($LogLevel.Length -gt 0) { $LogInfo += "[$LogLevel]" } $TextToFile = $Text -join '' $entry = If ($LogInfo) { "$LogInfo $TextToFile" } Else { $TextToFile } If (-not $NoNewLine) { $entry += [System.Environment]::NewLine } $logEncoding = Get-ColorLogEncoding -Name $Encoding $Saved = $False $Retry = 0 $attempts = [Math]::Max(1, $LogRetry) Do { $Retry++ try { $logFolder = [System.IO.Path]::GetDirectoryName($LogFilePath) If ($logFolder -and -not [System.IO.Directory]::Exists($logFolder)) { $null = [System.IO.Directory]::CreateDirectory($logFolder) } [System.IO.File]::AppendAllText($LogFilePath, $entry, $logEncoding) $Saved = $true Write-DebugLog "Successfully wrote to log file" } Catch { If ($Retry -ge $attempts) { Write-Warning "Write-ColorEX - Couldn't write to log file $($_.Exception.Message). Tried ($Retry/$attempts)" } Else { Write-DebugLog "Log write failed, retrying... ($Retry/$attempts)" Start-Sleep -Milliseconds 50 } } } Until ($Saved -or $Retry -ge $attempts) } Write-DebugLog "Write-ColorEX completed" } } |