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), rgb(r, g, b), hsl(h, s%, l%), RGB arrays @(R,G,B) and ANSI color numbers - Gradients across the characters of the text and of its background, between 2 or more colors - Markup tags that color and style part of a string, and patterns that color the text they match - Splitting a string into segments at separators or into equal parts, each with its own colors - Padding, centering and cutting to a display width (-AutoPad) that counts wide characters such as emoji and CJK as 2 cells, and wrapping to a width - Styles: Bold, Italic, Underline (single, double, curly, dotted or dashed, in a color of its own), Blink, Faint, CrossedOut, DoubleUnderline, Overline, Reverse - Links that terminals open when clicked - 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. These environment variables are read on each call, and the first of them that is set decides: - FORCE_COLOR=1, 2 or 3 sets the color mode: ANSI4, ANSI8 or TrueColor - FORCE_COLOR=0 or NO_COLOR turns colors and styles off - CLICOLOR_FORCE, set to anything but 0, keeps colors on: the support detected, or ANSI4 - CLICOLOR=0 or TERM=dumb turns colors and styles off .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', and the short forms '#F00' and '0xF00' - CSS forms: 'rgb(255, 0, 0)', and 'hsl(0, 100%, 50%)' with the hue in degrees - 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, an rgb() or hsl() color, 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 -ANSI8 or -ANSI4, a hex code, rgb() or hsl() color is written as the nearest color of that mode; an RGB array is one color only with -TrueColor. A color name is written in the mode the call uses: from its RGB value with -TrueColor or with a TrueColor color or a gradient in the call, as its 256-color number with -ANSI8, and otherwise as its 16-color code or console color. With fewer colors than text segments the colors repeat; extra colors are ignored. A $null or 'None' entry leaves its segment in the terminal's default color, or to the gradient with -Gradient. A name the color table lacks gives a warning and leaves its segment in the terminal's default color. 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) -TrueColor .PARAMETER BackGroundColor The background color of each text segment, in the same forms as -Color. A $null or 'None' entry leaves its segment on the terminal's default background, as does leaving it out. 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 a color in -Color keeps that color instead of the gradient, and a $null entry leaves its segment to the gradient. -Color repeats its colors over the segments, so -Color $null, 'Yellow', $null colors the second of three segments yellow and the others with the gradient. -GradientSpace sets how the colors blend. 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, Reverse 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 unknown, out of range or of the wrong form, about markup tags and -Highlight styles that are not styles, and about fallbacks to a color mode the terminal supports. .PARAMETER Markup Reads tags in the text that color and style part of it: [style]text[/]. A tag holds style names, a text color, 'on' and a background color, and link=URL, separated by spaces. [/] closes the tag opened last, and a tag left open closes at the end of its string. [[ and ]] write [ and ]. A tag that is not a style is written as it is, with a warning. - Style names: bold, faint (dim), italic, underline, doubleunderline, curly, dotted, dashed, blink, crossedout (strike, strikethrough), overline, reverse (invert) - Colors: the names Get-ColorTableWithRGB lists, hex codes, rgb(r, g, b) and hsl(h, s%, l%) A tag's colors go over -Color, -BackGroundColor and the gradients for its text. Tags nest: an inner tag takes the colors and link it does not set from the tag around it, and the styles of both. Example: -Text '[bold red]Error:[/] file not found' -Markup Example: -Text '[white on #005F87] OK [/] see [link=https://example.com]the log[/]' -Markup .PARAMETER Split Cuts each text segment after each of these separators, so -Color and the other parameters that take one value per segment color the parts in turn, without the text given in pieces. The separator stays at the end of the part before it. Separators are matched as written, with case; where several match at one place, the longest is used. Only one of -Split, -SplitAround and -SplitEvenly is used in a call. Example: -Text 'one,two,three' -Split ',' -Color Red, Green, Blue Output: "one," in red, "two," in green, "three" in blue .PARAMETER SplitAround Cuts each text segment before and after each of these separators, so each separator is a segment of its own and takes colors of its own. Example: -Text 'key=value' -SplitAround '=' -Color Cyan, DarkGray, White Output: "key" in cyan, "=" in dark gray, "value" in white .PARAMETER SplitEvenly Cuts each text segment into as many parts as -Color has colors (-BackGroundColor's, without -Color), of equal length in display characters. When they cannot be equal, the first parts are one character longer. Example: -Text 'STATUS' -SplitEvenly -Color Red, Yellow, Green Output: "ST" in red, "AT" in yellow, "US" in green .PARAMETER Highlight Colors and styles the text that patterns match: a hashtable of .NET regular expressions, each with a style written as a markup tag's contents. Matching ignores case and runs over the whole line. Where the matches of two patterns overlap, the pattern listed first wins; an ordered hashtable, [ordered]@{ }, keeps the order given. A pattern that is not a valid regular expression stops the command with an error, and a style that is not a style skips its pattern with a warning. Example: -Text 'ERROR: disk full on /dev/sda1' -Highlight @{ 'error' = 'bold red'; '/dev/\w+' = 'cyan underline' } .PARAMETER Link Makes each segment a link to the address given, which terminals that support links (OSC 8) open when it is clicked: Windows Terminal, iTerm2, WezTerm, Kitty, GNOME Terminal and others. The addresses repeat over the segments as -Color's colors do, and a $null entry leaves its segment without a link. Other terminals show the text alone. Links are written only where colors are. Example: -Text 'See ', 'the docs' -Link $null, 'https://github.com/MarkusMcNugen/PSWriteColorEX' .PARAMETER Reverse Swaps the text and background colors. Alias: Invert .PARAMETER BackGroundGradient Two or more colors to blend across the background of the text, as -Gradient does for the text. A segment with a color in -BackGroundColor keeps it, and a $null entry leaves its segment to the gradient. Alias: BGGrad Example: -Text ' Deploying ' -Color White -BackGroundGradient '#1D976C', '#93F9B9' .PARAMETER GradientSpace How -Gradient and -BackGroundGradient blend their colors: - OKLab: in the OKLab color space, where equal steps look equally far apart and the brightness stays even. Red to green passes through a golden yellow rather than a dark olive, and blue to yellow through a light blue rather than gray. - RGB: red, green and blue each on their own. Default: OKLab .PARAMETER UnderlineColor The color of each segment's underline, in the forms of -Color; the colors repeat over the segments. A segment with an underline color and no other underline is underlined with one line. A terminal without underline colors draws the underline in the text color. Example: -Text 'misspeled' -UnderlineStyle Curly -UnderlineColor Red .PARAMETER UnderlineStyle The kind of underline for every segment: Single, Double, Curly, Dotted or Dashed. A terminal without the kind draws a single line, or none. .PARAMETER Truncate With -AutoPad, cuts text wider than -AutoPad to fit, ending it with an ellipsis (…) in the colors of the text it replaces. Example: -Text 'C:\a\very\long\path\to\a\file.txt' -AutoPad 20 -Truncate Output: "C:\a\very\long\path…" .PARAMETER PadCenter With -AutoPad, centers the text, padding on both sides. With an odd number of padding characters, the right side has one more. Example: -Text 'Menu' -AutoPad 10 -PadCenter -PadChar '=' Output: "===Menu===" .PARAMETER Wrap Breaks text wider than the line into lines, at the last space that fits; a word wider than the line breaks between characters, and a line end in the text starts a new line. The width is -AutoPad, or without it the console window's width less the indentation and the time. Each line gets the indentation, and lines after the first get spaces in place of the time. With -AutoPad each line is padded. Without a known width, as with output redirected to a file, nothing wraps. With -Truncate, the text is cut to one line instead. .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. .EXAMPLE Write-ColorEX -Text '[bold green]PASS[/] 12 tests, [bold red]FAIL[/] 1 test' -Markup Writes "PASS" in bold green and "FAIL" in bold red, the rest in the terminal's colors. .EXAMPLE Write-ColorEX -Text 'GET /api/users 200 12ms' -Split ' ' -Color Cyan, White, Green, DarkGray Writes each word of a log line in its own color, the space after it with it. .EXAMPLE Get-Content app.log | Write-ColorEX -Highlight ([ordered]@{ '\bERROR\b' = 'bold red'; '\bWARN\b' = 'yellow'; '\d+ms' = 'cyan' }) Writes each line of a log file with ERROR, WARN and durations colored. .EXAMPLE Write-ColorEX -Text 'Release notes' -Link 'https://github.com/MarkusMcNugen/PSWriteColorEX/releases' -Underline Writes a link that terminals with link support open when it is clicked. .NOTES Name: Write-ColorEX Author: Mark Newton 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 .LINK Format-ColorEX #> [CmdletBinding()] [Alias('Write-ColourEX', 'Write-Color', 'Write-Colour', 'WC', 'WCEX', 'wcolor', 'wcolour')] param ( [Parameter(ValueFromPipeline = $true)] [alias ('T')][string[]] $Text, [alias ('C', 'ForegroundColor', 'FGC')][array] $Color = $null, [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 = ' ', [switch] $Markup, [string[]] $Split, [string[]] $SplitAround, [switch] $SplitEvenly, [System.Collections.IDictionary] $Highlight, [string[]] $Link, [alias('Invert')][switch] $Reverse, [AllowNull()] [alias ('BGGrad')][object[]] $BackGroundGradient = $null, [ValidateSet('OKLab', 'RGB')][string] $GradientSpace = 'OKLab', [array] $UnderlineColor = $null, [ValidateSet('Single', 'Double', 'Curly', 'Dotted', 'Dashed')][string] $UnderlineStyle, [switch] $Truncate, [switch] $PadCenter, [switch] $Wrap ) begin { # The folder of the script that called Write-ColorEX, for a bare -LogFile name $callerScriptRoot = $MyInvocation.PSScriptRoot # Each entry of -Color, -BackGroundColor and -UnderlineColor is a color: a string, an integer, or an array # of those and of arrays, such as an RGB array; or $null, which leaves its segment as it is. # A parameter check would refuse the $null entries, so the entries are checked here. foreach ($name in 'Color', 'BackGroundColor', 'UnderlineColor') { if (-not $PSBoundParameters.ContainsKey($name)) { continue } foreach ($value in @($PSBoundParameters[$name])) { $isColor = $null -eq $value -or $value -is [string] -or $value -is [int] if (-not $isColor -and $value -is [array]) { $isColor = $true foreach ($item in $value) { if ($item -isnot [string] -and $item -isnot [int] -and $item -isnot [array]) { $isColor = $false break } } } if (-not $isColor) { $shown = if ($value -is [array]) { $value.GetType().FullName } else { "$value" } $message = "Cannot validate argument on parameter '$name'. The argument `"$shown`" is not a color: a string, an integer, or an array of strings, integers and arrays." $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new( [System.ArgumentException]::new($message), 'ParameterArgumentValidationError', [System.Management.Automation.ErrorCategory]::InvalidData, $value)) } } } # One way of splitting at a time $splitCount = 0 if ($PSBoundParameters.ContainsKey('Split')) { $splitCount++ } if ($PSBoundParameters.ContainsKey('SplitAround')) { $splitCount++ } if ($SplitEvenly) { $splitCount++ } if ($splitCount -gt 1) { $PSCmdlet.ThrowTerminatingError([System.Management.Automation.ErrorRecord]::new( [System.ArgumentException]::new('Use only one of -Split, -SplitAround and -SplitEvenly.'), 'SplitConflict', [System.Management.Automation.ErrorCategory]::InvalidArgument, $null)) } # -Highlight's patterns, compiled once for every line $highlightEntries = @() if ($Highlight -and $Highlight.Count -gt 0) { if ($null -eq $script:CachedColorTable) { $script:CachedColorTable = Get-ColorTableWithRGB } $highlightEntries = ConvertFrom-ColorHighlight -Highlight $Highlight } # 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) { $variables = $ExecutionContext.SessionState.PSVariable $boundAtStart = @{} foreach ($name in $MyInvocation.MyCommand.Parameters.Keys) { # -Style and -UnderlineStyle are left out: the function does not change them, and # their parameter checks would refuse the empty values they hold when not given if ($name -eq 'Text' -or $name -eq 'Style' -or $name -eq 'UnderlineStyle') { 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]) } } if ($Debugging) { Write-DebugLog "Starting Write-ColorEX with Text count: $($Text.Count)" } # Format-ColorEX's list for the lines, which then go to it rather than to the host $captured = $script:CaptureLines if ($null -eq $script:CachedColorTable) { $script:CachedColorTable = Get-ColorTableWithRGB } $Colors = $script:CachedColorTable # The color names the table lacks, each warned about once per line $unknownColorNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) If ($Gradient -and $Gradient.Count -lt 2) { Write-ColorWarningMsg "Gradient requires at least 2 colors (received $($Gradient.Count)). Gradient disabled." if ($Debugging) { Write-DebugLog "Gradient validation failed: Only $($Gradient.Count) color(s) provided" } $Gradient = $null } If ($BackGroundGradient -and $BackGroundGradient.Count -lt 2) { Write-ColorWarningMsg "BackGroundGradient requires at least 2 colors (received $($BackGroundGradient.Count)). BackGroundGradient disabled." $BackGroundGradient = $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) { if ($Debugging) { Write-DebugLog "Using TrueColor mode (highest priority)" } $ANSI4 = $False $ANSI8 = $False } elseif ($ANSI8) { if ($Debugging) { Write-DebugLog "Using ANSI8 mode" } $ANSI4 = $False $ANSI24 = $False } else { if ($Debugging) { Write-DebugLog "Using ANSI4 mode" } $ANSI8 = $False $ANSI24 = $False } } if ($StyleProfile) { if ($Debugging) { Write-DebugLog "Applying style profile: $($StyleProfile.Name)" } $profileParams = $StyleProfile.ToWriteColorParams() foreach ($key in $profileParams.Keys) { if (-not $PSBoundParameters.ContainsKey($key)) { $ExecutionContext.SessionState.PSVariable.Set($key, $profileParams[$key]) } } } if ($Default -and [PSColorStyle]::Default) { if ($Debugging) { Write-DebugLog "Applying default style profile" } $defaultParams = [PSColorStyle]::Default.ToWriteColorParams() foreach ($key in $defaultParams.Keys) { if (-not $PSBoundParameters.ContainsKey($key) -and -not $StyleProfile) { $ExecutionContext.SessionState.PSVariable.Set($key, $defaultParams[$key]) } } } # The styles in use, kept apart from -Style, whose parameter check would refuse the empty # array the function puts in it. One style alone is the first segment's, as an array of # one; indexing the string itself would read its letters. $styles = $Style if ($styles -is [string]) { $styles = @($styles) } # Colors written #RGB, 0xRGB, rgb(r, g, b) or hsl(h, s%, l%) read as #RRGGBB, with a # warning for each name the color table lacks if ($null -ne $Color -and [ColorCode]::NeedsForm($Color, $Colors)) { $Color = ConvertTo-ColorFormList -Values $Color } if ($null -ne $BackGroundColor -and [ColorCode]::NeedsForm($BackGroundColor, $Colors)) { $BackGroundColor = ConvertTo-ColorFormList -Values $BackGroundColor } if ($null -ne $UnderlineColor -and [ColorCode]::NeedsForm($UnderlineColor, $Colors)) { $UnderlineColor = ConvertTo-ColorFormList -Values $UnderlineColor } if ($null -ne $Gradient -and [ColorCode]::NeedsForm($Gradient, $Colors)) { $Gradient = ConvertTo-ColorFormList -Values $Gradient } if ($null -ne $BackGroundGradient -and [ColorCode]::NeedsForm($BackGroundGradient, $Colors)) { $BackGroundGradient = ConvertTo-ColorFormList -Values $BackGroundGradient } # 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) -and ($null -ne $Color -or $null -ne $BackGroundColor -or $null -ne $UnderlineColor)) { foreach ($value in @($Color) + @($BackGroundColor) + @($UnderlineColor)) { if (($value -is [string] -and $value -match '^#|^0x') -or $value -is [array]) { $impliedTrueColor = $true break } } if ($impliedTrueColor) { if ($Debugging) { Write-DebugLog "Hex or RGB color without a color mode: using TrueColor" } $ANSI24 = $true $Color = [ColorCode]::ConsoleNumbers($Color) $BackGroundColor = [ColorCode]::ConsoleNumbers($BackGroundColor) $UnderlineColor = [ColorCode]::ConsoleNumbers($UnderlineColor) } } # Three integers for one segment under -TrueColor are one RGB color, not three colors if ($ANSI24 -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) { if ($Debugging) { Write-DebugLog "Detected flattened RGB array, wrapping: @($($Color[0]),$($Color[1]),$($Color[2]))" } $Color = ,@($Color[0], $Color[1], $Color[2]) } if ($ANSI24 -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) { if ($Debugging) { Write-DebugLog "Detected flattened RGB array for background, wrapping: @($($BackGroundColor[0]),$($BackGroundColor[1]),$($BackGroundColor[2]))" } $BackGroundColor = ,@($BackGroundColor[0], $BackGroundColor[1], $BackGroundColor[2]) } if ($ANSI24 -and $UnderlineColor -and $UnderlineColor.Count -eq 3 -and $UnderlineColor[0] -is [int] -and $UnderlineColor[1] -is [int] -and $UnderlineColor[2] -is [int] -and $Text.Count -eq 1) { $UnderlineColor = ,@($UnderlineColor[0], $UnderlineColor[1], $UnderlineColor[2]) } # The text as segments, each a list of runs: text with the colors, styles and link that # markup and -Highlight give it. -Split, -SplitAround and -SplitEvenly cut the segments. $segments = [System.Collections.Generic.List[object]]::new() foreach ($item in $Text) { if ($Markup) { $runs = ConvertFrom-ColorMarkup -Text $item if ($runs.Count -eq 0) { $runs.Add(@{ Text = ''; HasFg = $false; HasBg = $false; HasLink = $false; Styles = $null }) } } else { $runs = [System.Collections.Generic.List[object]]::new() $runs.Add(@{ Text = [string]$item; HasFg = $false; HasBg = $false; HasLink = $false; Styles = $null }) } $segments.Add($runs) } if ($Split -or $SplitAround) { $separators = if ($Split) { $Split } else { $SplitAround } $segments = Split-ColorSegment -Segments $segments -Separators $separators -Around:([bool]$SplitAround) } elseif ($SplitEvenly) { $parts = if ($Color.Count -gt 0) { $Color.Count } else { $BackGroundColor.Count } if ($parts -gt 1) { $segments = Split-ColorSegment -Segments $segments -Parts $parts } } if ($highlightEntries.Count -gt 0) { $segments = Add-ColorHighlight -Segments $segments -Entries $highlightEntries } # Whether markup or -Highlight gives runs colors, or styles or links, which need escape # codes. A hex code among their colors asks for TrueColor, as in -Color. $runColors = $false $runEscapes = $false if ($Markup -or $highlightEntries.Count -gt 0) { $runHex = $false foreach ($segment in $segments) { foreach ($run in $segment) { if ($run.HasFg -or $run.HasBg) { $runColors = $true if (($run.HasFg -and $run.Fg -match '^#|^0x') -or ($run.HasBg -and $run.Bg -match '^#|^0x')) { $runHex = $true } } if ($run.HasLink -or ($null -ne $run.Styles -and $run.Styles.Count -gt 0)) { $runEscapes = $true } } } if ($runHex -and -not ($ANSI4 -or $ANSI8 -or $ANSI24)) { if ($Debugging) { Write-DebugLog "Hex or RGB color without a color mode: using TrueColor" } $impliedTrueColor = $true $ANSI24 = $true $Color = [ColorCode]::ConsoleNumbers($Color) $BackGroundColor = [ColorCode]::ConsoleNumbers($BackGroundColor) $UnderlineColor = [ColorCode]::ConsoleNumbers($UnderlineColor) } } # The color mode asked for, before any fallback, which the color checks read $OriginalTrueColor = [bool]$ANSI24 $OriginalANSI8 = [bool]$ANSI8 $OriginalANSI4 = [bool]$ANSI4 # Padding to a display width, measured so wide characters count as 2 cells; with -Wrap, # each line is padded when it is written if ($AutoPad -gt 0) { if ($Debugging) { 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." } if ($Truncate) { $segments = Limit-ColorSegmentWidth -Segments $segments -Width $AutoPad } if (-not $Wrap) { $currentWidth = Measure-DisplayWidth -Text ([ColorCode]::SegmentText($segments)) if ($Debugging) { 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) { if ($Debugging) { 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) { if ($Debugging) { Write-DebugLog "Adding $padCount '$PadChar' character(s) = $($padCount * $padCharWidth) cells" } if ($PadCenter) { $leftCount = [int][Math]::Floor($padCount / 2) if ($leftCount -gt 0) { $segments.Insert(0, [ColorCode]::Segment($PadChar.ToString() * $leftCount)) } $segments.Add([ColorCode]::Segment($PadChar.ToString() * ($padCount - $leftCount))) if ($Debugging) { Write-DebugLog "Applied padding on both sides (centered text)" } } elseif ($PadLeft) { $segments.Insert(0, [ColorCode]::Segment($PadChar.ToString() * $padCount)) if ($Debugging) { Write-DebugLog "Applied left padding (right-aligned text)" } } else { $segments.Add([ColorCode]::Segment($PadChar.ToString() * $padCount)) if ($Debugging) { Write-DebugLog "Applied right padding (left-aligned text)" } } } } else { if ($Debugging) { Write-DebugLog "Text width ($currentWidth) >= Target width ($AutoPad). No padding applied." } } } } # The color variables, read on each call since they can change at any time. FORCE_COLOR 1 to # 3 keeps colors on in its mode; FORCE_COLOR=0 and NO_COLOR turn them off; CLICOLOR_FORCE, # set to anything but 0, keeps them on; CLICOLOR=0 and TERM=dumb turn them off. The first # of these set decides. $forceColor = [System.Environment]::GetEnvironmentVariable('FORCE_COLOR') $forcedColor = $forceColor -in @('1', '2', '3') $cliColorForced = $false $ColorDisabled = $false if (-not $forcedColor) { if ($forceColor -eq '0' -or -not [string]::IsNullOrEmpty([System.Environment]::GetEnvironmentVariable('NO_COLOR'))) { $ColorDisabled = $true } else { $cliColorForce = [System.Environment]::GetEnvironmentVariable('CLICOLOR_FORCE') if (-not [string]::IsNullOrEmpty($cliColorForce) -and $cliColorForce -ne '0') { $cliColorForced = $true } elseif ([System.Environment]::GetEnvironmentVariable('CLICOLOR') -eq '0' -or [System.Environment]::GetEnvironmentVariable('TERM') -eq 'dumb') { $ColorDisabled = $true } } } $UsingANSIFeatures = $ANSI4 -or $ANSI8 -or $ANSI24 -or $Bold -or $Italic -or $Underline -or $Blink -or $Faint -or $CrossedOut -or $DoubleUnderline -or $Overline -or $styles -or $Gradient -or $Reverse -or $UnderlineStyle -or $UnderlineColor -or $Link -or $BackGroundGradient -or $runEscapes $ComposeLine = $false If ($ColorDisabled) { if ($Debugging) { Write-DebugLog "Colors are off: NO_COLOR, FORCE_COLOR=0, CLICOLOR=0 or TERM=dumb" } $ANSISupport = $False $ANSIColorSupport = 'None' $styles = @() $Gradient = $null $BackGroundGradient = $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' if ($forcedColor -or $cliColorForced) { # FORCE_COLOR and CLICOLOR_FORCE keep the colors as escape codes, whatever the host $ComposeLine = $null -ne $captured -or -not $NoConsoleOutput } elseif ($null -ne $captured) { # A string for Format-ColorEX holds escape codes wherever the host shows them $ComposeLine = $script:CachedANSISupport -ne 'None' -and (Test-ColorHostAnsi) } else { $ComposeLine = (-not $NoConsoleOutput) -and (Test-ColorLineComposition) } if ($Debugging) { Write-DebugLog "Console colors only; one call per line: $ComposeLine" } } Else { if ($forcedColor) { if ($Debugging) { Write-DebugLog "FORCE_COLOR environment variable detected: $forceColor" } switch ($forceColor) { '1' { $ANSIColorSupport = 'ANSI4' } '2' { $ANSIColorSupport = 'ANSI8' } '3' { $ANSIColorSupport = 'TrueColor' } } if ($Debugging) { Write-DebugLog "FORCE_COLOR override: $ANSIColorSupport" } } elseif ($cliColorForced) { # CLICOLOR_FORCE keeps the support detected, or the 16 colors where none was found if ($null -eq $script:CachedANSISupport) { $script:CachedANSISupport = (Test-AnsiSupport -Silent).ColorSupport } $ANSIColorSupport = if ($script:CachedANSISupport -eq 'None') { 'ANSI4' } else { $script:CachedANSISupport } if ($Debugging) { Write-DebugLog "CLICOLOR_FORCE 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 if ($Debugging) { Write-DebugLog "The host renders no escape codes; using console colors" } $ANSIColorSupport = 'None' } if ($Debugging) { Write-DebugLog "ANSI Color Support: $ANSIColorSupport (cached)" } } $ANSISupport = $ANSIColorSupport -ne 'None' If ($ANSIColorSupport -eq 'None') { $styles = @() $ANSI4 = $False $ANSI8 = $False $ANSI24 = $False if ($Debugging) { 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)." } if ($Debugging) { 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)." } if ($Debugging) { 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)." if ($Debugging) { Write-DebugLog "Downgrading from ANSI8 to ANSI4" } $ANSI8 = $False $ANSI4 = $True } If ($Gradient -and $Gradient.Count -ge 2) { if ($Debugging) { 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." if ($Debugging) { 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." if ($Debugging) { Write-DebugLog "Gradient disabled: ANSI4 only" } $Gradient = $null } Else { If (-not $ANSI8 -and -not $ANSI24) { If ($ANSIColorSupport -eq 'TrueColor') { if ($Debugging) { Write-DebugLog "Gradient: Auto-enabling TrueColor mode" } $ANSI24 = $True } Else { if ($Debugging) { Write-DebugLog "Gradient: Auto-enabling ANSI8 mode" } $ANSI8 = $True } } if ($Debugging) { Write-DebugLog "Gradient enabled in $ANSIColorSupport mode" } } } If ($BackGroundGradient -and $BackGroundGradient.Count -ge 2) { If ($ANSIColorSupport -eq 'None') { Write-ColorWarningMsg "BackGroundGradient requires ANSI 256-color or TrueColor support. Terminal supports: None. BackGroundGradient disabled." $BackGroundGradient = $null } ElseIf ($ANSIColorSupport -eq 'ANSI4') { Write-ColorWarningMsg "BackGroundGradient requires ANSI 256-color or TrueColor support. Terminal supports: ANSI4 (16 colors). BackGroundGradient disabled." $BackGroundGradient = $null } ElseIf (-not $ANSI8 -and -not $ANSI24) { If ($ANSIColorSupport -eq 'TrueColor') { $ANSI24 = $True } Else { $ANSI8 = $True } } } } If (-not $NoConsoleOutput) { $WindowWidth = 0 If ($BlankLine -or $HorizontalCenter -or ($Wrap -and $AutoPad -le 0)) { $WindowWidth = Get-ColorHostWidth } If ($BlankLine) { if ($Debugging) { Write-DebugLog "Processing blank line" } $HorizontalCenter = $False $StartTab = 0 $StartSpaces = 0 $ShowTime = $False $Wrap = $False $segments = [System.Collections.Generic.List[object]]::new() $segments.Add([ColorCode]::Segment(' ' * $WindowWidth)) } $timeText = '' If ($ShowTime) { $timeText = "[$([datetime]::Now.ToString($DateTimeFormat))] " } # The lines to write, each a list of items: the segment whose colors a piece takes, and # its runs. Without -Wrap, one line of every segment. $segmentCount = $segments.Count $lines = [System.Collections.Generic.List[object]]::new() $wrapWidth = 0 If ($Wrap) { $wrapWidth = if ($AutoPad -gt 0) { $AutoPad } else { $WindowWidth - 8 * $StartTab - $StartSpaces - (Measure-DisplayWidth -Text $timeText) } } If ($wrapWidth -gt 0) { $lines = Split-ColorLine -Segments $segments -Width $wrapWidth If ($AutoPad -gt 0) { $side = if ($PadCenter) { 'Center' } elseif ($PadLeft) { 'Left' } else { 'Right' } $padded = Add-ColorLinePadding -Lines $lines -SegmentCount $segmentCount -Width $AutoPad -PadChar $PadChar.ToString() -PadCharWidth $padCharWidth -Side $side $lines = $padded.Lines $segmentCount = $padded.SegmentCount } } Else { $items = [System.Collections.Generic.List[object]]::new() For ($i = 0; $i -lt $segments.Count; $i++) { $items.Add(@{ Index = $i; Runs = $segments[$i] }) } $lines.Add($items) } # Each run's characters, and where it starts in the gradients, which run across every # character written, padding included. A color code is never put inside a character. $totalChars = 0 If (($Gradient -and $Gradient.Count -ge 2) -or ($BackGroundGradient -and $BackGroundGradient.Count -ge 2)) { foreach ($items in $lines) { foreach ($item in $items) { foreach ($run in $item.Runs) { if ($null -eq $run.Characters) { $run.Characters = @(Split-DisplayCharacter -Text $run.Text) } $run.GradientIndex = $totalChars $totalChars += $run.Characters.Count } } } } $gradientMode = if ($ANSI24) { 'TrueColor' } else { 'ANSI8' } $gradientArray = $null If ($Gradient -and $Gradient.Count -ge 2) { if ($Debugging) { Write-DebugLog "Calculating gradient for text" } if ($Debugging) { 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." if ($Debugging) { Write-DebugLog "Gradient disabled: More colors ($($Gradient.Count)) than characters ($totalChars)" } $Gradient = $null } else { if ($Debugging) { Write-DebugLog "Generating gradient in $gradientMode mode" } $gradientArray = New-GradientColorArray -Colors $Gradient -Steps $totalChars -Mode $gradientMode -Space $GradientSpace if (-not $gradientArray) { if ($Debugging) { Write-DebugLog "Gradient array generation failed" } $gradientArray = $null $Gradient = $null } } } $bgGradientArray = $null If ($BackGroundGradient -and $BackGroundGradient.Count -ge 2) { if ($BackGroundGradient.Count -gt $totalChars) { Write-ColorWarningMsg "BackGroundGradient has $($BackGroundGradient.Count) colors but text only has $totalChars characters. Applying standard coloring instead." $BackGroundGradient = $null } else { $bgGradientArray = New-GradientColorArray -Colors $BackGroundGradient -Steps $totalChars -Mode $gradientMode -Space $GradientSpace if (-not $bgGradientArray) { $bgGradientArray = $null $BackGroundGradient = $null } } } # Colors that need no check, lightening or fallback conversion are taken as they are: # a name in the color table (its RGB in TrueColor), a hex code in TrueColor, and a # code 0-255 in ANSI8. The others go through ConvertTo-ForegroundModeColor and # ConvertTo-BackgroundModeColor, which warn and write debug messages. $directColors = -not $Debugging -and -not ($Bold -and -not $script:SupportsBoldFonts) # Each segment's text color, cycling through -Color, converted for the mode in use If ($Color.Count -gt 0 -and -not $ColorDisabled) { if ($Debugging) { Write-DebugLog "Processing $($Color.Count) colors" } $ProcessedColors = [System.Collections.Generic.List[object]]::new() For ($i = 0; $i -lt $segmentCount; $i++) { $value = $Color[$i % $Color.Count] if ($directColors) { if ($value -is [string]) { if ($Colors.ContainsKey($value)) { if ($ANSI24) { $ProcessedColors.Add($Colors[$value][4]) } else { $ProcessedColors.Add($value) } continue } if ($ANSI24) { $rgb = [ColorCode]::HexToRgb($value) if ($null -ne $rgb) { $ProcessedColors.Add($rgb) continue } } } elseif ($value -is [int] -and $ANSI8 -and $OriginalANSI8 -and $value -ge 0 -and $value -le 255) { $ProcessedColors.Add($value) continue } } $ProcessedColors.Add((ConvertTo-ForegroundModeColor -Value $value -Index $i -Original $value)) } $Color = $ProcessedColors.ToArray() } Else { $Color = @() } # Each segment's background color, cycling through -BackGroundColor, converted for the mode in use If ($BackGroundColor.Count -gt 0 -and -not $ColorDisabled) { if ($Debugging) { Write-DebugLog "Processing $($BackGroundColor.Count) background colors" } $ProcessedBGColors = [System.Collections.Generic.List[object]]::new() For ($i = 0; $i -lt $segmentCount; $i++) { $value = $BackGroundColor[$i % $BackGroundColor.Count] if ($directColors) { if ($value -is [string]) { if ($Colors.ContainsKey($value)) { if ($ANSI24) { $ProcessedBGColors.Add($Colors[$value][4]) } else { $ProcessedBGColors.Add($value) } continue } if ($ANSI24) { $rgb = [ColorCode]::HexToRgb($value) if ($null -ne $rgb) { $ProcessedBGColors.Add($rgb) continue } } } elseif ($value -is [int] -and $ANSI8 -and $OriginalANSI8 -and $value -ge 0 -and $value -le 255) { $ProcessedBGColors.Add($value) continue } } $ProcessedBGColors.Add((ConvertTo-BackgroundModeColor -Value $value -Index $i -Original $value)) } $BackGroundColor = $ProcessedBGColors.ToArray() } Else { $BackGroundColor = @() } # The colors markup and -Highlight give runs, converted for the mode in use If ($runColors -and -not $ColorDisabled) { foreach ($items in $lines) { foreach ($item in $items) { foreach ($run in $item.Runs) { if ($run.HasFg) { $run.ModeFg = ConvertTo-ForegroundModeColor -Value $run.Fg -Index $item.Index -Original $run.Fg } if ($run.HasBg) { $run.ModeBg = ConvertTo-BackgroundModeColor -Value $run.Bg -Index $item.Index -Original $run.Bg } } } } } # Each segment's underline color, cycling through -UnderlineColor. A segment with no # other underline takes a single one. $underlineCodes = [System.Collections.Generic.List[string]]::new() If ($ANSISupport -and $UnderlineColor.Count -gt 0) { $underlined = $Underline -or $DoubleUnderline -or $UnderlineStyle For ($i = 0; $i -lt $segmentCount; $i++) { $code = Get-UnderlineColorSequence -Value $UnderlineColor[$i % $UnderlineColor.Count] if ($code -and -not $underlined) { $ownUnderline = $false if ($styles) { foreach ($name in @($styles[$i])) { if ($name -in 'Underline', 'DoubleUnderline', 'Curly', 'Dotted', 'Dashed') { $ownUnderline = $true } } } if (-not $ownUnderline) { $code = $script:UnderlineStyleSgr['Single'] + $code } } $underlineCodes.Add($code) } } # Each segment's link, cycling through -Link, where escape codes reach the terminal $linksOn = [bool]$ANSISupport $segmentLinks = [System.Collections.Generic.List[object]]::new() If ($linksOn -and $Link.Count -gt 0) { For ($i = 0; $i -lt $segmentCount; $i++) { $segmentLinks.Add([ColorCode]::Link($Link[$i % $Link.Count])) } } # What writes each line: the colors, styles, underline colors, links and gradients $writer = [ColorLine]::new() $writer.LineStyles = '' $writer.ANSISupport = [bool]$ANSISupport $writer.ANSI24 = [bool]$ANSI24 $writer.ANSI8 = [bool]$ANSI8 $writer.ANSI4 = [bool]$ANSI4 $writer.Colors = $Colors $writer.Styles = $styles $writer.Foregrounds = $Color $writer.Backgrounds = $BackGroundColor $writer.Underlines = $underlineCodes.ToArray() $writer.Links = $segmentLinks.ToArray() $writer.LinksOn = $linksOn $writer.Gradient = $gradientArray $writer.BackGroundGradient = $bgGradientArray # The styles of every segment, after each segment's own from -Style If ($ANSISupport) { $lineStyles = '' if ($Bold) { $lineStyles += [ColorCode]::StyleSgr['Bold'] } if ($Faint) { $lineStyles += [ColorCode]::StyleSgr['Faint'] } if ($Italic) { $lineStyles += [ColorCode]::StyleSgr['Italic'] } if ($Underline) { $lineStyles += [ColorCode]::StyleSgr['Underline'] } if ($Blink) { $lineStyles += [ColorCode]::StyleSgr['Blink'] } if ($CrossedOut) { $lineStyles += [ColorCode]::StyleSgr['CrossedOut'] } if ($DoubleUnderline) { $lineStyles += [ColorCode]::StyleSgr['DoubleUnderline'] } if ($Overline) { $lineStyles += [ColorCode]::StyleSgr['Overline'] } if ($Reverse) { $lineStyles += [ColorCode]::StyleSgr['Reverse'] } if ($UnderlineStyle) { $lineStyles += [ColorCode]::UnderlineStyleSgr[$UnderlineStyle] } $writer.LineStyles = $lineStyles } if ($Debugging) { Write-DebugLog "Starting text output" } # What comes before the text: centering, tabs and spaces, then the time. Lines after the # first take spaces in place of the time. $indent = '' If ($StartTab -gt 0) { $indent += "`t" * $StartTab } If ($StartSpaces -gt 0) { $indent += ' ' * $StartSpaces } For ($i = 0; $i -lt $LinesBefore; $i++) { if ($null -ne $captured) { $captured.Add(''); continue } Write-Host '' } For ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) { $items = $lines[$lineIndex] $lineNoNewLine = $NoNewLine -and $lineIndex -eq $lines.Count - 1 $lineText = $null $prefix = '' If ($HorizontalCenter -and $WindowWidth -gt 0) { $lineText = [ColorCode]::ItemText($items) $MessageLength = Measure-DisplayWidth -Text $lineText If ($WindowWidth -ge $MessageLength) { $CenterPosition = [int][Math]::Max(0, $WindowWidth / 2 - [Math]::Floor($MessageLength / 2)) $prefix += ' ' * $CenterPosition } } $prefix += $indent $lineTime = $timeText If ($lineIndex -gt 0 -and $timeText) { $prefix += ' ' * (Measure-DisplayWidth -Text $timeText) $lineTime = '' } If ($ColorDisabled) { # One call, no colors or styles if ($null -eq $lineText) { $lineText = [ColorCode]::ItemText($items) } $line = $prefix + $lineTime + $lineText If ($null -ne $captured) { $captured.Add($line) } ElseIf ($line.Length -gt 0 -or -not $lineNoNewLine) { Write-Host -Object $line -NoNewline:$lineNoNewLine } } ElseIf ($ANSISupport -or $ComposeLine) { # One call, the colors and styles as escape codes if ($Debugging -and $gradientArray) { Write-DebugLog "Using gradient mode for output" foreach ($item in $items) { if ($item.Index -lt $Color.Count -and $null -ne $Color[$item.Index]) { Write-DebugLog "Segment $($item.Index) has explicit color override (skipping gradient)" } } } $line = $prefix If ($lineTime) { $line += "$script:Esc[90m$lineTime$script:SgrReset" } $line += $writer.Ansi($items) If ($null -ne $captured) { $captured.Add($line) } ElseIf ($line.Length -gt 0 -or -not $lineNoNewLine) { Write-Host -Object $line -NoNewline:$lineNoNewLine } } Else { # One call per color, each with -ForegroundColor and -BackgroundColor $merged = $writer.Pieces($prefix, $lineTime, $items) If ($null -ne $captured) { # Console colors cannot be held in a string: the text alone $captured.Add((-join @(foreach ($piece in $merged) { $piece.Text }))) } ElseIf ($merged.Count -eq 0) { If (-not $lineNoNewLine) { 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 $lineNoNewLine } if ($piece.Fg) { $hostParameters['ForegroundColor'] = $piece.Fg } if ($piece.Bg) { $hostParameters['BackgroundColor'] = $piece.Bg } Write-Host @hostParameters } } } } For ($i = 0; $i -lt $LinesAfter; $i++) { if ($null -ne $captured) { $captured.Add(''); continue } Write-Host '' } } If ($segments.Count -and $LogFile) { if ($Debugging) { 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 = [ColorCode]::SegmentText($segments) $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 if ($Debugging) { 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 { if ($Debugging) { Write-DebugLog "Log write failed, retrying... ($Retry/$attempts)" } Start-Sleep -Milliseconds 50 } } } Until ($Saved -or $Retry -ge $attempts) } if ($Debugging) { Write-DebugLog "Write-ColorEX completed" } } } |