Classes/ColorCode.ps1

<#
.SYNOPSIS
    The escape codes and pieces of text Write-ColorEX builds for each line.
 
.DESCRIPTION
    A call to a static method or to a method of an object costs a fraction of a function call, so
    the work Write-ColorEX does for every segment of every line is done here. The methods read
    nothing from the caller: every value comes in as a parameter or a property. The functions in
    Private/WriteColorCore.ps1 do the work that writes warnings and debug messages.
 
    - ColorCode: the escape code tables, and static methods for color and style codes, console
      color names, color forms and segments
    - ColorLine: the colors of one Write-ColorEX call, with methods that write a line as escape
      codes or as pieces for Write-Host's -ForegroundColor and -BackgroundColor
 
.NOTES
    Author: Mark Newton
    License: MIT
    Requires: PowerShell 5.1 or later
#>


class ColorCode {
    static [string] $Esc = [string][char]27
    static [string] $Reset = "$([char]27)[0m"

    # The ANSI4 text code of each console color; a background code is 10 more
    static [hashtable] $ConsoleSgr = @{
        Black = 30; DarkRed = 31; DarkGreen = 32; DarkYellow = 33
        DarkBlue = 34; DarkMagenta = 35; DarkCyan = 36; Gray = 37
        DarkGray = 90; Red = 91; Green = 92; Yellow = 93
        Blue = 94; Magenta = 95; Cyan = 96; White = 97
    }

    # The ANSI color number 0-15 of each console color, for an underline color in 16 colors
    static [hashtable] $ConsoleAnsiIndex = @{
        Black = 0; DarkRed = 1; DarkGreen = 2; DarkYellow = 3; DarkBlue = 4; DarkMagenta = 5; DarkCyan = 6; Gray = 7
        DarkGray = 8; Red = 9; Green = 10; Yellow = 11; Blue = 12; Magenta = 13; Cyan = 14; White = 15
    }

    # The escape code of each style name
    static [hashtable] $StyleSgr = @{
        'Reset' = "$([char]27)[0m"
        'Bold' = "$([char]27)[1m"
        'Faint' = "$([char]27)[2m"
        'Italic' = "$([char]27)[3m"
        'Underline' = "$([char]27)[4m"
        'Blink' = "$([char]27)[5m"
        'CrossedOut' = "$([char]27)[9m"
        'DoubleUnderline' = "$([char]27)[21m"
        'Overline' = "$([char]27)[53m"
        'Reverse' = "$([char]27)[7m"
        'Curly' = "$([char]27)[4:3m"
        'Dotted' = "$([char]27)[4:4m"
        'Dashed' = "$([char]27)[4:5m"
        'None' = ''
    }

    # The escape code of each -UnderlineStyle
    static [hashtable] $UnderlineStyleSgr = @{
        'Single' = "$([char]27)[4m"
        'Double' = "$([char]27)[21m"
        'Curly' = "$([char]27)[4:3m"
        'Dotted' = "$([char]27)[4:4m"
        'Dashed' = "$([char]27)[4:5m"
    }

    # The escape code that sets a color in the color mode in use, or '' for none. A string is a
    # name in the color table, an integer a code of the mode, an array of three an RGB color.
    # Without an ANSI mode the color is the console color as an ANSI4 code.
    static [string] ColorSequence([object] $Value, [bool] $Background, [bool] $ANSI24, [bool] $ANSI8, [bool] $ANSI4, [hashtable] $Colors) {
        if ($null -eq $Value) {
            return ''
        }
        $layer = 38
        if ($Background) {
            $layer = 48
        }
        if ($ANSI24 -and $Value -is [array] -and $Value.Count -eq 3) {
            return "$([ColorCode]::Esc)[$layer;2;$($Value[0]);$($Value[1]);$($Value[2])m"
        }
        if ($ANSI8) {
            if ($Value -is [string]) {
                $entry = $Colors[$Value]
                if ($entry) {
                    return "$([ColorCode]::Esc)[$layer;5;$($entry[3])m"
                }
            } elseif ($Value -is [int]) {
                return "$([ColorCode]::Esc)[$layer;5;$($Value)m"
            }
            return ''
        }
        if ($ANSI4) {
            if ($Value -is [string]) {
                $entry = $Colors[$Value]
                if ($entry) {
                    $code = $entry[1]
                    if ($Background) {
                        $code = $entry[2]
                    }
                    return "$([ColorCode]::Esc)[$($code)m"
                }
            } elseif ($Value -is [int]) {
                return "$([ColorCode]::Esc)[$($Value)m"
            }
            return ''
        }
        # A console color name maps to itself in the color table
        $number = $null
        if ($Value -is [string]) {
            $number = [ColorCode]::ConsoleSgr[$Value]
        }
        if ($null -eq $number) {
            $number = [ColorCode]::ConsoleSgr[[ColorCode]::NativeName($Value, $Background, $Colors)]
        }
        if ($Background) {
            $number += 10
        }
        return "$([ColorCode]::Esc)[$($number)m"
    }

    # The console color for a color value. An unknown name or value is Gray for text and Black for
    # a background.
    static [string] NativeName([object] $Value, [bool] $Background, [hashtable] $Colors) {
        $fallback = 'Gray'
        if ($Background) {
            $fallback = 'Black'
        }
        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 styles -Style gives one segment: a name, or an array of names, at its index
    static [string] SegmentStyle([object] $Styles, [int] $Index) {
        if (-not $Styles) {
            return ''
        }
        $own = $null
        if ($Styles -is [array]) {
            if ($Index -lt $Styles.Count) {
                $own = $Styles[$Index]
            }
        } elseif ($Index -eq 0) {
            $own = $Styles
        }
        if (-not $own) {
            return ''
        }
        if ($own -is [array]) {
            $builder = [System.Text.StringBuilder]::new()
            foreach ($name in $own) {
                if ($null -ne $name) {
                    [void]$builder.Append([ColorCode]::StyleSgr[$name])
                }
            }
            return $builder.ToString()
        }
        if ($own -is [string]) {
            return [ColorCode]::StyleSgr[$own]
        }
        return ''
    }

    # The escape codes of the styles a markup tag or a -Highlight style gives a run
    static [string] RunStyle([object] $Styles) {
        if ($null -eq $Styles) {
            return ''
        }
        $builder = [System.Text.StringBuilder]::new()
        foreach ($name in $Styles) {
            [void]$builder.Append([ColorCode]::StyleSgr[$name])
        }
        return $builder.ToString()
    }

    # A link's address with its control characters removed, or '' for none
    static [string] Link([object] $Value) {
        if ($null -eq $Value) {
            return ''
        }
        return [regex]::Replace([string]$Value, '[\x00-\x1F\x7F]', '')
    }

    # Whether a color parameter holds a string that is neither a name in the color table, nor a
    # six-digit hex code, nor 'None' or empty: a color form to read, or an unknown name to warn about
    static [bool] NeedsForm([object[]] $Values, [hashtable] $Colors) {
        if ($null -eq $Values) {
            return $false
        }
        foreach ($value in $Values) {
            if ($value -is [string] -and -not $Colors.ContainsKey($value) -and
                $value -notmatch '^(#|0x)[0-9A-Fa-f]{6}$' -and $value -ne 'None' -and
                -not [string]::IsNullOrWhiteSpace($value)) {
                return $true
            }
        }
        return $false
    }

    # Console color numbers 0-15 as their names, so they keep their meaning in another color mode
    static [object[]] ConsoleNumbers([object[]] $Values) {
        $converted = [System.Collections.Generic.List[object]]::new()
        if ($null -eq $Values) {
            return $converted.ToArray()
        }
        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 RGB color of a hex code #RRGGBB or 0xRRGGBB, or $null for any other text
    static [object[]] HexToRgb([string] $Hex) {
        if ($Hex -notmatch '^(?:#|0x)([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})([0-9A-Fa-f]{2})$') {
            return $null
        }
        return @([Convert]::ToInt32($Matches[1], 16), [Convert]::ToInt32($Matches[2], 16), [Convert]::ToInt32($Matches[3], 16))
    }

    # A segment of one run of text, with no colors, styles or link of its own
    static [System.Collections.Generic.List[object]] Segment([string] $Text) {
        $segment = [System.Collections.Generic.List[object]]::new()
        $segment.Add(@{ Text = $Text; HasFg = $false; HasBg = $false; HasLink = $false; Styles = $null })
        return $segment
    }

    # The text of segments, their runs joined
    static [string] SegmentText([System.Collections.Generic.List[object]] $Segments) {
        $builder = [System.Text.StringBuilder]::new()
        foreach ($segment in $Segments) {
            foreach ($run in $segment) {
                [void]$builder.Append($run.Text)
            }
        }
        return $builder.ToString()
    }

    # The text of a line's items, their runs joined
    static [string] ItemText([System.Collections.Generic.List[object]] $Items) {
        $builder = [System.Text.StringBuilder]::new()
        foreach ($item in $Items) {
            foreach ($run in $item.Runs) {
                [void]$builder.Append($run.Text)
            }
        }
        return $builder.ToString()
    }
}

class ColorLine {
    # Whether escape codes for styles reach the terminal, and the color mode in use
    [bool] $ANSISupport
    [bool] $ANSI24
    [bool] $ANSI8
    [bool] $ANSI4
    # The color table
    [hashtable] $Colors
    # -Style, one entry per segment, and the escape codes of the styles every segment takes
    [object] $Styles
    [string] $LineStyles
    # Each segment's text and background color, underline color code, and link ('' for none)
    [object[]] $Foregrounds
    [object[]] $Backgrounds
    [string[]] $Underlines
    [object[]] $Links
    # Whether links are written
    [bool] $LinksOn
    # The text and background gradients, one color for each character written, or $null
    [object[]] $Gradient
    [object[]] $BackGroundGradient

    # The text of a line with its colors, styles and links as escape codes.
    #
    # Items are the line's pieces in order, each @{ Index; Runs }: the segment whose colors,
    # styles, underline color and link it takes, and its runs. A run's markup or -Highlight colors
    # and styles go over the segment's. A segment with no text color of its own takes the
    # gradient, character by character from the run's GradientIndex, and one with no background
    # color the background gradient. Each run with codes ends with a reset; a link opens before
    # the codes of the first run it covers and closes after the last.
    [string] Ansi([System.Collections.Generic.List[object]] $Items) {
        $esc = [ColorCode]::Esc
        $reset = [ColorCode]::Reset
        $builder = [System.Text.StringBuilder]::new()
        $openLink = ''
        foreach ($item in $Items) {
            $i = [int]$item.Index
            $segmentStyles = ''
            if ($this.ANSISupport) {
                $segmentStyles = $this.LineStyles
                if ($this.Styles) {
                    $segmentStyles = [ColorCode]::SegmentStyle($this.Styles, $i) + $this.LineStyles
                }
            }
            $segmentFg = $null
            if ($i -lt $this.Foregrounds.Count) {
                $segmentFg = $this.Foregrounds[$i]
            }
            $segmentBg = $null
            if ($i -lt $this.Backgrounds.Count) {
                $segmentBg = $this.Backgrounds[$i]
            }
            $segmentUnderline = ''
            if ($i -lt $this.Underlines.Count) {
                $segmentUnderline = $this.Underlines[$i]
            }
            $segmentLink = ''
            if ($i -lt $this.Links.Count) {
                $segmentLink = [string]$this.Links[$i]
            }
            foreach ($run in $item.Runs) {
                $runLink = $segmentLink
                if ($run.HasLink) {
                    $runLink = [ColorCode]::Link($run.Link)
                }
                if ($this.LinksOn -and $runLink -cne $openLink) {
                    if ($openLink.Length -gt 0) {
                        [void]$builder.Append("$esc]8;;$esc\")
                    }
                    if ($runLink.Length -gt 0) {
                        [void]$builder.Append("$esc]8;;$runLink$esc\")
                    }
                    $openLink = $runLink
                }
                $codes = $segmentStyles + $segmentUnderline
                if ($this.ANSISupport -and $null -ne $run.Styles -and $run.Styles.Count -gt 0) {
                    $codes += [ColorCode]::RunStyle($run.Styles)
                }
                $fg = $segmentFg
                if ($run.HasFg) {
                    $fg = $run.ModeFg
                }
                $bg = $segmentBg
                if ($run.HasBg) {
                    $bg = $run.ModeBg
                }
                $fgGradient = $null -ne $this.Gradient -and -not $run.HasFg -and $null -eq $segmentFg
                $bgGradient = $null -ne $this.BackGroundGradient -and -not $run.HasBg -and $null -eq $segmentBg
                if ($fgGradient -or $bgGradient) {
                    if (-not $fgGradient -and $null -ne $fg) {
                        $codes += [ColorCode]::ColorSequence($fg, $false, $this.ANSI24, $this.ANSI8, $this.ANSI4, $this.Colors)
                    }
                    if (-not $bgGradient -and $null -ne $bg) {
                        $codes += [ColorCode]::ColorSequence($bg, $true, $this.ANSI24, $this.ANSI8, $this.ANSI4, $this.Colors)
                    }
                    [void]$builder.Append($codes)
                    $at = [int]$run.GradientIndex
                    foreach ($character in $run.Characters) {
                        if ($fgGradient) {
                            $step = $this.Gradient[$at]
                            if ($this.ANSI24 -and $step -is [array]) {
                                [void]$builder.Append("$esc[38;2;$($step[0]);$($step[1]);$($step[2])m")
                            } elseif ($this.ANSI8 -and $step -is [int]) {
                                [void]$builder.Append("$esc[38;5;$($step)m")
                            }
                        }
                        if ($bgGradient) {
                            $step = $this.BackGroundGradient[$at]
                            if ($this.ANSI24 -and $step -is [array]) {
                                [void]$builder.Append("$esc[48;2;$($step[0]);$($step[1]);$($step[2])m")
                            } elseif ($this.ANSI8 -and $step -is [int]) {
                                [void]$builder.Append("$esc[48;5;$($step)m")
                            }
                        }
                        [void]$builder.Append($character)
                        $at++
                    }
                    [void]$builder.Append($reset)
                    continue
                }
                if ($null -ne $fg) {
                    $codes += [ColorCode]::ColorSequence($fg, $false, $this.ANSI24, $this.ANSI8, $this.ANSI4, $this.Colors)
                }
                if ($null -ne $bg) {
                    $codes += [ColorCode]::ColorSequence($bg, $true, $this.ANSI24, $this.ANSI8, $this.ANSI4, $this.Colors)
                }
                [void]$builder.Append($codes).Append($run.Text)
                if ($codes.Length -gt 0 -or ($null -ne $this.Gradient -and $null -ne $segmentFg)) {
                    [void]$builder.Append($reset)
                }
            }
        }
        if ($openLink.Length -gt 0) {
            [void]$builder.Append("$esc]8;;$esc\")
        }
        return $builder.ToString()
    }

    # The pieces of a line written with console colors, one Write-Host call each: text with the
    # console colors of its run or segment. White space with no background joins its neighbor,
    # and pieces of one color pair go out together.
    [System.Collections.Generic.List[object]] Pieces([string] $Prefix, [string] $TimeText, [System.Collections.Generic.List[object]] $Items) {
        $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 })
        }
        foreach ($item in $Items) {
            $i = [int]$item.Index
            foreach ($run in $item.Runs) {
                $fgValue = $null
                if ($run.HasFg) {
                    $fgValue = $run.ModeFg
                } elseif ($i -lt $this.Foregrounds.Count) {
                    $fgValue = $this.Foregrounds[$i]
                }
                $bgValue = $null
                if ($run.HasBg) {
                    $bgValue = $run.ModeBg
                } elseif ($i -lt $this.Backgrounds.Count) {
                    $bgValue = $this.Backgrounds[$i]
                }
                $fg = $null
                $bg = $null
                if ($null -ne $fgValue) {
                    $fg = [ColorCode]::NativeName($fgValue, $false, $this.Colors)
                }
                if ($null -ne $bgValue) {
                    $bg = [ColorCode]::NativeName($bgValue, $true, $this.Colors)
                }
                $pieces.Add(@{ Text = [string]$run.Text; Fg = $fg; Bg = $bg })
            }
        }

        $merged = [System.Collections.Generic.List[object]]::new()
        foreach ($piece in $pieces) {
            if ($piece.Text.Length -eq 0) {
                continue
            }
            $previousPiece = $null
            if ($merged.Count -gt 0) {
                $previousPiece = $merged[$merged.Count - 1]
            }
            $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)
        }
        return $merged
    }
}