Private/ColorAndIcon.ps1

function script:Get-Translate {
    $translate = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase)
    $translate.Add('BLK', 'bd')
    $translate.Add('CAPABILITY', 'ca')
    $translate.Add('CHR', 'cd')
    $translate.Add('DIR', 'di')
    $translate.Add('DOOR', 'do')
    $translate.Add('EXEC', 'ex')
    $translate.Add('FIFO', 'pi')
    $translate.Add('FILE', 'fi')
    $translate.Add('HIDDEN', 'hi')
    $translate.Add('LINK', 'ln')
    $translate.Add('MISSING', 'mi')
    $translate.Add('MULTIHARDLINK', 'mh')
    $translate.Add('NORMAL', 'no')
    $translate.Add('ORPHAN', 'or')
    $translate.Add('OTHER_WRITABLE', 'ow')
    $translate.Add('RESET', 'rs')
    $translate.Add('SETGID', 'sg')
    $translate.Add('SETUID', 'su')
    $translate.Add('SOCK', 'so')
    $translate.Add('STICKY', 'st')
    $translate.Add('STICKY_OTHER_WRITABLE', 'tw')
    return $translate    
}

$script:TranslateCache = Get-Translate

function script:ConvertFrom-SourceData {
    param(
        [string]$SourceFile
    )

    if (-not [System.IO.File]::Exists($SourceFile)) { return $null }

    $filters = [System.Collections.Generic.HashSet[string]]::new(2048)
    $null = $filters.Add('TERM')
    $null = $filters.Add('COLOR')
    $null = $filters.Add('*')

    $lines = [System.IO.File]::ReadAllLines($SourceFile)
    if ($lines.Count -eq 0) { return $null }

    $result = [System.Text.StringBuilder]::new(16384)

    foreach ($line in $lines) {
        # remove comments and trim whitespace
        $cleanLine = $line.Split('#')[0].Trim()
        if (-not $cleanLine) { continue }

        # use regex to split by the last occurrence of whitespace, to allow keys with spaces (like "Saved Games")
        $parts = [regex]::Split($cleanLine, '\s+(?=\S+$)')

        if ($parts.Count -lt 2) { continue }

        $key = $parts[0]
        $val = $parts[1]

        if ($val -eq "*" -or $val -eq "" -or $filters.Contains($key)) { continue }

        $finalKey = $null
        if ($script:TranslateCache.TryGetValue($key, [ref]$finalKey)) {
            # Found a translation, use it
        }
        elseif ($key.StartsWith('*.')) {
            $finalKey = $key.ToLower()
        }
        elseif ($key.StartsWith('.')) {
            $finalKey = "*" + $key.ToLower()
        }
        else {
            $finalKey = $key
        }

        if ($filters.Contains($finalKey)) { continue }
        
        $null = $filters.Add($key)
        $null = $filters.Add($finalKey)
        if ($result.Length -gt 0) { $null = $result.Append(':') }
        $null = $result.Append($finalKey).Append('=').Append($val)
    }

    return $result.ToString()
}

function script:Get-CacheData {
    param(
        [string]$SourceFile,
        [string]$CacheFile
    )
    if ([System.IO.File]::Exists($CacheFile)) {
        $cached = [System.IO.File]::ReadAllText($CacheFile, [System.Text.Encoding]::UTF8)
        if ($cached) { return $cached }
    }
    $result = (ConvertFrom-SourceData $SourceFile)
    if ($result) {
        [System.IO.File]::WriteAllText($CacheFile, $result, [System.Text.Encoding]::UTF8)
    }
    return $result
}

$script:DefaultColors = @{
    'fi' = '0'                   # Default file no color
    'di' = '38;5;30'             # Directory default blue-green
    'ln' = '38;5;81;1'           # Link default cyan bold
    'or' = '48;5;196;38;5;232;1' # Orphan default red background with black bold
    'ex' = '38;5;208;1'          # Executable file default orange bold
    'hi' = '38;5;90'             # Hidden file default purple-gray
    'pi' = '38;5;126'            # FIFO default yellow-green
    'so' = '38;5;197'            # Socket default pink
}

$script:DefaultIcons = @{
    'fi' = '' # File default file icon
    'di' = '' # Directory default folder icon
    'ln' = '' # Link default link icon
    'or' = '' # Orphan default broken link icon
    'ex' = '' # Executable file default program icon
    'hi' = '' # Hidden file default hidden icon
    'pi' = '' # FIFO default pipe icon
    'so' = '' # Socket default socket icon
}

$script:COLORS_SOURCE = Join-Path $PSScriptRoot '../Data/LS_COLORS'
$script:COLORS_CACHE = Join-Path $HOME '.LS_COLORS_CACHE'
$script:ICONS_SOURCE = Join-Path $PSScriptRoot '../Data/LS_ICONS'
$script:ICONS_CACHE = Join-Path $HOME '.LS_ICONS_CACHE'

function script:Get-Colors {
    return Get-CacheData $script:COLORS_SOURCE $script:COLORS_CACHE
}

function script:Get-Icons {
    return Get-CacheData $script:ICONS_SOURCE $script:ICONS_CACHE
}

$script:ColorsMemCache = [PSCustomObject]@{
    Hash   = [System.Collections.Generic.Dictionary[string, string]]::new()
    IsInit = $false
}

$script:IconsMemCache = [PSCustomObject]@{
    Hash   = [System.Collections.Generic.Dictionary[string, string]]::new()
    IsInit = $false
}

function script:ConvertTo-MemCache {
    param($EnvVar)
    $hash = [System.Collections.Generic.Dictionary[string, string]]::new([System.StringComparer]::OrdinalIgnoreCase) # Store exact match and suffix match (.py, di)

    foreach ($item in ($EnvVar -split ':')) {
        $kv = $item -split '='
        if ($kv.Count -ne 2) { continue }
        $key = $kv[0]
        $val = $kv[1]
        if ($key -match '^(.*)\[0-9\]\{0,(\d+)\}$') {
            $prefix = $Matches[1].TrimStart('*')
            $maxLen = [int]$Matches[2]
            # To prevent generating too many entries,
            # we can limit the maxLen to a reasonable number, say 3.
            # This means we will generate entries for up to 999 suffixes.
            if ($maxLen -gt 3) { $maxLen = 3 }
            $hash[$prefix] = $val
            $maxN = [math]::Pow(10, $maxLen) - 1
            foreach ($i in 0..$maxN) {
                $n = "$i"
                foreach ($j in $n.Length..$maxLen) {
                    $fmt = $n.PadLeft($j, '0')
                    $hash[$prefix + $fmt] = $val
                }
            }
        }
        elseif ($key.StartsWith('*')) {
            $hash[$key.TrimStart('*')] = $val
        }
        else {
            $hash[$key] = $val
        }
    }

    return $hash
}

function script:Initialize-ColorsMemCache {
    if (-not $script:ColorsMemCache.IsInit) {
        if (-not $env:LS_COLORS) {
            $env:LS_COLORS = Get-Colors
        }
        $script:ColorsMemCache.Hash = ConvertTo-MemCache $env:LS_COLORS
        $script:ColorsMemCache.IsInit = $true
    }
}

function script:Initialize-IconsMemCache {
    if (-not $script:IconsMemCache.IsInit) {
        if (-not $env:LS_ICONS) {
            $env:LS_ICONS = Get-Icons
        }
        $script:IconsMemCache.Hash = ConvertTo-MemCache $env:LS_ICONS
        $script:IconsMemCache.IsInit = $true
    }
}

Initialize-ColorsMemCache
Initialize-IconsMemCache

function script:Update-ColorsCache {
    Remove-Item $script:COLORS_CACHE -ErrorAction SilentlyContinue
    $env:LS_COLORS = Get-Colors
    $script:ColorsMemCache.IsInit = $false # Force reinitialize color cache
    Initialize-ColorsMemCache
}

function script:Update-IconsCache {
    Remove-Item $script:ICONS_CACHE -ErrorAction SilentlyContinue
    $env:LS_ICONS = Get-Icons
    $script:IconsMemCache.IsInit = $false # Force reinitialize icon cache
    Initialize-IconsMemCache
}

$script:CoolWatcher = New-Object System.IO.FileSystemWatcher
$script:CoolWatcher.Path = Join-Path $PSScriptRoot '../Data'
$script:CoolWatcher.NotifyFilter = [System.IO.NotifyFilters]::LastWrite
$script:CoolWatcher.EnableRaisingEvents = $true

$global:LastReload = Get-Date

$oldEvent = Get-EventSubscriber -SourceIdentifier "CoolFileWatcher" -ErrorAction SilentlyContinue
if ($oldEvent) { Unregister-Event -SourceIdentifier "CoolFileWatcher" }

$null = Register-ObjectEvent -InputObject $script:CoolWatcher -EventName Changed -SourceIdentifier "CoolFileWatcher" -Action {
    try {
        if ((Get-Date) -lt $global:LastReload.AddMilliseconds(500)) { return }
        $global:LastReload = Get-Date
    
        $fileName = [System.IO.Path]::GetFileName($Event.SourceEventArgs.Name)
        Start-Sleep -Milliseconds 100
    
        $m = Get-Module Cool
        & $m {
            param($name)
            switch ($name) {
                "LS_COLORS" { Update-ColorsCache }
                "LS_ICONS" { Update-IconsCache }
            }
        } $fileName
    }
    catch {}
}

function script:Lookup {
    param($DefaultHash, $Hash, $Name, $Ext, $Attr)
    if ($null -ne $Name -and $Hash.ContainsKey($Name)) {
        return $Hash[$Name]
    }
    if ($null -ne $Ext -and $Hash.ContainsKey($Ext)) {
        return $Hash[$Ext] 
    }
    if ($null -eq $Attr) {
        return $null
    }
    if ($Hash.ContainsKey($Attr)) {
        return $Hash[$Attr]
    }
    return $DefaultHash[$Attr]
}
function script:Get-DefaultColor {
    param($Attr)
    return $script:DefaultColors[$Attr]
}

function script:Get-DefaultIcon {
    param($Attr)
    return $script:DefaultIcons[$Attr]
}

function script:Get-Color {
    param($Name, $Ext, $Attr)
    $color = Lookup $script:DefaultColors $script:ColorsMemCache.Hash $Name $Ext $Attr
    if ($null -eq $color -or $color -eq 'target') {
        $color = Get-DefaultColor $Attr
    }
    return $color
}

function script:Get-Icon {
    param($Name, $Ext, $Attr)
    $icon = Lookup $script:DefaultIcons $script:IconsMemCache.Hash $Name $Ext $Attr
    if ($null -eq $icon -or $icon -eq 'target') {
        $icon = Get-DefaultIcon $Attr
    }
    return $icon
}

function script:EscapeColor {
    param($Color)
    return "$([char]27)[${Color}m"
}

function script:FontBold {
    param($Text)
    return "$(EscapeColor '1')${Text}$(EscapeColor '22')"
}

function script:FontDim {
    param($Text)
    return "$(EscapeColor '2')${Text}$(EscapeColor '22')"
}

function script:FontItalic {
    param($Text)
    return "$(EscapeColor '3')${Text}$(EscapeColor '23')"
}

function script:FontUnderline {
    param($Text)
    return "$(EscapeColor '4')${Text}$(EscapeColor '24')"
}

function script:FontBlinking {
    param($Text)
    return "$(EscapeColor '5')${Text}$(EscapeColor '25')"
}

function script:FontReverse {
    param($Text)
    return "$(EscapeColor '7')${Text}$(EscapeColor '27')"
}

function script:FontHidden {
    param($Text)
    return "$(EscapeColor '8')${Text}$(EscapeColor '28')"
}

function script:FontStrikeThrough {
    param($Text)
    return "$(EscapeColor '9')${Text}$(EscapeColor '29')"
}

function script:ColorReset {
    return EscapeColor '0'
}

# Red, Orange, Yellow, Green, Cyan, Blue, Purple, Gray, Silver, White 10 color cycle, index 0-9
$script:Colors = @(196, 208, 220, 40, 81, 33, 135, 242, 250, 253) | ForEach-Object { EscapeColor "38;5;$_" }

function script:Color {
    param($Index)
    return $script:Colors[$Index]
}

function script:ColorRed {
    return $script:Colors[0]
}

function script:ColorOrange {
    return $script:Colors[1]
}

function script:ColorYellow {
    return $script:Colors[2]
}

function script:ColorGreen {
    return $script:Colors[3]
}

function script:ColorCyan {
    return $script:Colors[4]
}

function script:ColorBlue {
    return $script:Colors[5]
}

function script:ColorPurple {
    return $script:Colors[6]
}

function script:ColorGray {
    return $script:Colors[7]
}

function script:ColorSilver {
    return $script:Colors[8]
}

function script:ColorWhite {
    return $script:Colors[9]
}

function script:Format-CoolCommandName {
    param(
        [System.Management.Automation.CommandInfo]$CmdInfo
    )
    if ($CmdInfo.CommandType -eq 'Application') {
        $name = $CmdInfo.Name
        $ext = $CmdInfo.Extension.ToLower()
        $attr = if ($ext -eq ".sock" -or $ext -eq ".socket") { 'so' }
        elseif ($ext -match '\.(com|exe|bat|cmd|ps1|sh)$') { 'ex' }
        else { 'fi' }

        $color = Get-Color $name $ext $attr
        $icon = Get-Icon $name $ext $attr

        if ($color -eq '0') { 
            return "$(vPadRight $icon 3)$($CmdInfo.Name)"
        }
        return "$(EscapeColor $color)$(vPadRight $icon 3)$($CmdInfo.Name)$(ColorReset)"
    }
    $color, $icon = switch ($CmdInfo.CommandType) {
        'Alias' { @((ColorPurple), '') }
        'Filter' { @((ColorYellow), '') }
        'Cmdlet' { @((ColorBlue), '') }
        'ExternalScript' { @((ColorBlue), '') }
        'Script' { @((ColorGreen), '') }
        'Function' { if ($CmdInfo.Name -match '^[A-Z]\:$') { @((ColorRed), '') } else { @((ColorCyan), '') } }
        Default { @((ColorGray), '') }
    }
    return "$color$(vPadRight $icon 3)$($CmdInfo.Name)$(ColorReset)"
}

function script:Format-CoolNameFromFsInfo {
    param(
        [System.IO.FileSystemInfo]$FsInfo
    )
    # Get basic attrs
    $name = $FsInfo.Name
    $ext = $FsInfo.Extension.ToLower()
    $attrs = $FsInfo.Attributes
    $fa = [System.IO.FileAttributes]
    $isLink = $attrs.HasFlag($fa::ReparsePoint)

    $attr = if ($isLink) {
        if ($FsInfo.PSObject.Methods['ResolveLinkTarget']) {
            try {
                $target = $FsInfo.ResolveLinkTarget($true);
                $name = $target.Name
                $ext = $target.Extension.ToLower()
                'ln'
            }
            catch { 'or' }
        }
        else { 'ln' }
    }
    elseif ($attrs.HasFlag($fa::Hidden)) { 'hi' }
    elseif ($FsInfo -is [System.IO.DirectoryInfo]) { 'di' }
    elseif ($attrs.HasFlag($fa::SparseFile)) { 'pi' }
    elseif ($ext -eq ".sock" -or $ext -eq ".socket") { 'so' }
    elseif ($ext -match '\.(com|exe|bat|cmd|ps1|sh)$') { 'ex' }
    else { 'fi' }

    # Get initial color and icon
    $color = Get-Color $name $ext $attr
    $icon = Get-Icon $name $ext $attr

    # Final rendering
    if ($attrs.HasFlag($fa::System)) {
        $color += ';2' # Dim system files
    }
    if ($attrs.HasFlag($fa::ReadOnly)) {
        $color += ';4' # Underline read-only files
    }

    if ($color -eq '0') { 
        return "$(vPadRight $icon 3)$($FsInfo.Name)"
    }
    return "$(EscapeColor $color)$(vPadRight $icon 3)$($FsInfo.Name)$(ColorReset)"
}

function script:Format-CoolNameFromPath {
    param(
        [string]$Path
    )
    $base = $Path
    $ext = ""
    $lastDotIndex = $Path.LastIndexOf('.')
    $lastSlashIndex = $Path.LastIndexOf('/')
    if ($lastDotIndex -gt $lastSlashIndex) {
        $base = $Path.Substring(0, $lastDotIndex)
        $ext = $Path.Substring($lastDotIndex)
    }
    if ($lastSlashIndex -ge 0) {
        $base = $Path.Substring($lastSlashIndex + 1)
    }
    $name = $base + $ext
    $attr = if ([System.IO.Directory]::Exists($Path)) { 'di' }
    elseif ($ext -eq ".sock" -or $ext -eq ".socket") { 'so' }
    elseif ($ext -match '\.(com|exe|bat|cmd|ps1|sh)$') { 'ex' }
    else { 'fi' }

    $color = Get-Color $name $ext $attr
    $icon = Get-Icon $name $ext $attr

    if ($color -eq '0') { 
        return "$(vPadRight $icon 3)$name"
    }
    return "$(EscapeColor $color)$(vPadRight $icon 3)$name$(ColorReset)"
}

function global:Format-CoolName {
    <#
    .SYNOPSIS
        Formats the input filesystem object as a string with color and icon.
    .PARAMETER Item
        A CommandInfo/FileSystemInfo object or string representing a file or directory.
    .OUTPUT
        A string with ANSI color codes and an icon, visually representing the file or directory.
    #>

    param(
        $Item
    )
    if ($Item -is [System.Management.Automation.CommandInfo]) {
        return Format-CoolCommandName $Item
    }
    elseif ($Item -is [System.IO.FileSystemInfo]) {
        return Format-CoolNameFromFsInfo $Item
    }
    elseif ($Item -is [string]) {
        return Format-CoolNameFromPath $Item
    }
}

function global:Format-CoolSize {
    <#
    .SYNOPSIS
        Formats bytes into a colorful, human-readable string (B, KB, MB, GB, TB, PB, EB).
        The digital part is 7 chars wide, total output is 10 chars wide.
    .PARAMETER Bytes
        The size in bytes to format.
    .PARAMETER ValueColor
        Optional color for the numeric part.
    .OUTPUT
        A string with ANSI color codes, where the numeric part is right-aligned to 7 characters,
        followed by a space and a 2-character unit, for a total width of 10 characters.
    .EXAMPLE
        Format-CoolSize -Bytes 123456789 -ValueColor (ColorRed)
        Output: " 117.74 MB" with "117.74" in red and "MB" in the color corresponding to its unit.
    #>

    param(
        [double]$Bytes,
        [string]$ValueColor = "" # Optional color for the numeric part
    )
    $units = ' B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'
    
    # Handle zero or negative values
    if ($Bytes -le 0) {
        return "$(ColorGray) 0 B$(ColorReset)"
    }

    $index = 0
    $value = $Bytes

    # Calculate unit level
    while ($value -ge 1024 -and $index -lt ($units.Count - 1)) {
        $value /= 1024
        $index++
    }

    # Format numeric part (PadLeft 7)
    $formattedValue = ("{0:N2}" -f $value).PadLeft(7)
    $unit = $units[$index]
    
    # Get colors
    $unitColor = Color $index

    # Combine: Value(7) + Space(1) + Unit(2) = 10 chars
    return "${ValueColor}${formattedValue} ${unitColor}$unit$(ColorReset)"
}