AsciiFX.psm1

# AsciiFX: ASCII animations for the terminal and the desktop.
#
# Every folder in animations/ is one animation: a C# class plus a manifest (<name>.psd1) that lists its options.
# This file reads those manifests and creates two commands for each animation:
# <name> plays it full-screen in the terminal
# desktop-<name> shows it on the desktop, above the wallpaper and behind your windows (Windows only)

$script:Animations = [ordered]@{}
foreach ($dir in Get-ChildItem (Join-Path $PSScriptRoot 'animations') -Directory | Where-Object Name -NotLike '_*' | Sort-Object Name) {
    $manifest = Import-PowerShellDataFile (Join-Path $dir.FullName "$($dir.Name).psd1")
    $script:Animations[$manifest.Name] = $manifest
}

$script:Positions = 'center', 'top', 'bottom', 'left', 'right', 'top-left', 'top-right', 'bottom-left', 'bottom-right'

#region Compiling

# The C# is compiled the first time it is needed and cached under a hash of its source,
# so later sessions just load the cached DLL
function Get-CompiledAssembly([string]$Name, [string[]]$Sources, [string[]]$References) {
    $text = ($Sources | ForEach-Object { [IO.File]::ReadAllText($_) }) -join "`n"
    $key = $text + ($References -join ';') + $PSVersionTable.PSEdition + $PSVersionTable.PSVersion.Major
    $sha = [Security.Cryptography.SHA256]::Create()
    $hash = -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($key))[0..5] | ForEach-Object { $_.ToString('x2') })

    $cache = Join-Path $(if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { Join-Path $HOME '.cache' }) 'AsciiFX'
    New-Item -ItemType Directory -Force $cache | Out-Null
    $dll = Join-Path $cache "$Name.$hash.dll"
    if (-not (Test-Path $dll)) {
        Write-Host 'AsciiFX: preparing animations (first run only)...' -ForegroundColor DarkGray
        # Compile from copies in the cache folder: the C# compiler can't read files from very long paths
        $staging = Join-Path $cache "src-$hash"
        New-Item -ItemType Directory -Force $staging | Out-Null
        try {
            $copies = foreach ($source in $Sources) {
                $copy = Join-Path $staging ([IO.Path]::GetFileName($source))
                Copy-Item $source $copy -Force
                $copy
            }
            $compile = @{ Path = $copies; OutputAssembly = $dll; OutputType = 'Library' }
            if ($References) { $compile.ReferencedAssemblies = $References }
            Add-Type @compile
        } finally {
            Remove-Item $staging -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
    $dll
}

function Initialize-AsciiFX([switch]$Desktop) {
    if (-not ('AsciiFX.IAnimation' -as [type])) {
        $sources = @(Join-Path $PSScriptRoot 'core/AsciiFX.Core.cs') +
            @(Get-ChildItem (Join-Path $PSScriptRoot 'animations') -Recurse -Filter *.cs |
                Where-Object { $_.Directory.Name -notlike '_*' } | Sort-Object FullName | ForEach-Object FullName)
        $dll = Get-CompiledAssembly 'AsciiFX.Animations' $sources
        if (-not ('AsciiFX.IAnimation' -as [type])) { Add-Type -Path $dll }
    }
    # The desktop window always runs in Windows PowerShell 5.1 (see Invoke-DesktopAnimation)
    if ($Desktop -and -not ('AsciiFX.DesktopPlayer' -as [type])) {
        Add-Type -AssemblyName System.Windows.Forms, System.Drawing
        $references = @([AsciiFX.IAnimation].Assembly.Location, 'System.Windows.Forms', 'System.Drawing')
        $dll = Get-CompiledAssembly 'AsciiFX.Desktop' @(Join-Path $PSScriptRoot 'core/AsciiFX.Desktop.cs') $references
        if (-not ('AsciiFX.DesktopPlayer' -as [type])) { Add-Type -Path $dll }
    }
}

#endregion

#region Running animations

# What an animation gets: its manifest defaults, then the desktop overrides, then whatever was typed
function Get-AnimationOptions($Animation, [string]$Mode, [Collections.IDictionary]$Bound) {
    $options = @{}
    if ($Animation.Palettes) { $options.Palette = $Animation.Palettes[0] }
    foreach ($o in $Animation.Options) {
        if ($o.Type -ne 'switch' -and $null -ne $o.Default) { $options[$o.Name] = $o.Default }
    }
    if ($Mode -eq 'desktop' -and $Animation.Desktop.Options) {
        foreach ($key in $Animation.Desktop.Options.Keys) { $options[$key] = $Animation.Desktop.Options[$key] }
    }
    foreach ($key in $Bound.Keys) {
        $options[$key] = if ($Bound[$key] -is [switch]) { $Bound[$key].IsPresent } else { $Bound[$key] }
    }
    $options
}

function New-AnimationInstance($Animation, [hashtable]$Options) {
    [Activator]::CreateInstance(($Animation.Class -as [type]), @(, [AsciiFX.Options]::new($Options)))
}

function Invoke-TerminalAnimation([string]$Name, [Collections.IDictionary]$Bound) {
    Initialize-AsciiFX
    $options = Get-AnimationOptions $script:Animations[$Name] 'terminal' $Bound
    $seconds = if ($options.Seconds) { [double]$options.Seconds } else { 0 }
    $fps = if ($options.Fps) { [int]$options.Fps } else { 0 }
    [AsciiFX.TerminalPlayer]::Run((New-AnimationInstance $script:Animations[$Name] $options), $seconds, $fps)
}

function Test-Windows { $PSVersionTable.PSEdition -eq 'Desktop' -or $IsWindows }

# Every desktop animation is its own hidden PowerShell running core/desktop-host.ps1. Its spot
# (-Position/-X/-Y) is the end of that command line, which is how one gets found again to replace or remove.
function Stop-DesktopAnimation([string]$Name = '*', [string]$Spot = '') {
    Get-CimInstance Win32_Process -Filter "Name = 'powershell.exe' OR Name = 'pwsh.exe'" |
        Where-Object { $_.CommandLine -like "*desktop-host.ps1*-Animation $Name *$Spot" } |
        ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
}

function Invoke-DesktopAnimation([string]$Name, [Collections.IDictionary]$Bound) {
    if (-not (Test-Windows)) { Write-Error 'Desktop animations need Windows.'; return }
    $animation = $script:Animations[$Name]
    $sky = $animation.Desktop.Layout -eq 'sky'
    $spot = if ($sky) { '-Position sky -X 0 -Y 0' } else {
        "-Position $(if ($Bound.Position) { $Bound.Position } else { 'center' }) " +
        "-X $(if ($Bound.ContainsKey('X')) { $Bound.X } else { -1 }) -Y $(if ($Bound.ContainsKey('Y')) { $Bound.Y } else { -1 })"
    }

    # -Stop alone removes every one of this animation; with a spot, just that one
    $spotGiven = @($Bound.Keys | Where-Object { $_ -in 'Position', 'X', 'Y' }).Count -gt 0
    Stop-DesktopAnimation $Name $(if ($Bound.Stop -and ($sky -or -not $spotGiven)) { '' } else { $spot })
    if ($Bound.Stop) { return }

    # -Size is the font size here, not the animation's own Size option
    $typed = @{}
    foreach ($key in $Bound.Keys) { $typed[$key] = $Bound[$key] }
    $fontSize = if ($typed.ContainsKey('Size')) { $typed.Size } else { $animation.Desktop.FontSize }
    $typed.Remove('Size')
    $options = Get-AnimationOptions $animation 'desktop' $typed
    $own = @($animation.Options | ForEach-Object Name) + 'Palette'
    $encoded = ($options.GetEnumerator() | Where-Object { $_.Key -in $own -or $animation.Desktop.Options.Keys -contains $_.Key } |
        ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ';'

    # Launched through WMI, so it keeps running after the terminal that started it closes. It always uses the
    # Windows PowerShell 5.1 built into Windows, where Windows Forms compiles without extra setup, even from PowerShell 7.
    $exe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
    $hostScript = Join-Path $PSScriptRoot 'core/desktop-host.ps1'
    $hidden = New-CimInstance -ClassName Win32_ProcessStartup -ClientOnly -Property @{ ShowWindow = [uint16]0 }
    $null = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
        CommandLine = "`"$exe`" -NoProfile -ExecutionPolicy Bypass -File `"$hostScript`" -Animation $Name " +
                      "-Options `"$encoded`" -FontSize $fontSize $spot"
        ProcessStartupInformation = $hidden
    }
}

# Called inside the hidden PowerShell that desktop-host.ps1 starts
function Start-DesktopWindow([string]$Name, [string]$Encoded, [int]$FontSize, [string]$Position, [int]$X, [int]$Y) {
    Initialize-AsciiFX -Desktop
    $animation = $script:Animations[$Name]
    $options = @{}
    foreach ($pair in $Encoded -split ';') { if ($pair -match '^(.+?)=(.*)$') { $options[$Matches[1]] = $Matches[2] } }
    $height = if ($options.Height) { [int]$options.Height } else { 40 }
    $d = $animation.Desktop
    [AsciiFX.DesktopPlayer]::Run((New-AnimationInstance $animation $options), $Name, $FontSize, $d.Layout,
        [int]$d.Cols, [int]$d.Rows, $height, [bool]$d.Outline, $Position, $X, $Y)
}

#endregion

#region Commands

function Format-Default($Value) {
    if ($Value -is [string]) { "'$Value'" }
    elseif ($Value -is [bool]) { "`$$Value" }
    else { [string]::Format([Globalization.CultureInfo]::InvariantCulture, '{0}', $Value) }
}

# Builds the source of `<name>` (Mode terminal) or `desktop-<name>` (Mode desktop) from a manifest, help included.
# A variant (from the manifest's Variants list) is the same animation under its own command name with other defaults.
function New-CommandText($Animation, [string]$Mode, $Variant) {
    $name = $Animation.Name
    $command = if ($Variant) { $Variant.Command } elseif ($Mode -eq 'desktop') { "desktop-$name" } else { $name }
    $description = if ($Variant) { $Variant.Description } else { $Animation.Description }
    $defaults = if ($Variant -and $Variant.Defaults) { $Variant.Defaults } else { @{} }
    $params = New-Object Collections.Generic.List[string]
    $help = New-Object Collections.Generic.List[string]

    $help.Add('.SYNOPSIS')
    $help.Add($(if ($Mode -eq 'desktop') { "$description, on the desktop." } else { "$description, in the terminal." }))
    $help.Add('.DESCRIPTION')
    $help.Add($(if ($Mode -eq 'desktop') {
        'Shows the animation on the desktop: above the wallpaper, behind your windows, and click-through. ' +
        'Run it again for another spot, or with -Stop to remove it. Windows only.'
    } else { 'Plays full-screen in the terminal. Press any key to stop.' }))

    if ($Animation.Palettes) {
        $params.Add("[Parameter(Position = 0)][ValidateSet($(($Animation.Palettes | ForEach-Object { "'$_'" }) -join ', '))][string]`$Palette = '$($Animation.Palettes[0])'")
        $help.Add('.PARAMETER Palette'); $help.Add("Color scheme: $($Animation.Palettes -join ', ')")
    }
    if ($Mode -eq 'terminal') {
        $params.Add('[double]$Seconds = 0'); $help.Add('.PARAMETER Seconds'); $help.Add('Stop after this many seconds (0 = until a key is pressed)')
        $params.Add('[int]$Fps = 0'); $help.Add('.PARAMETER Fps'); $help.Add('Frames per second (0 = what the animation looks best at)')
    } else {
        $params.Add('[switch]$Stop'); $help.Add('.PARAMETER Stop')
        $help.Add($(if ($Animation.Desktop.Layout -eq 'sky') { 'Remove it' } else { 'Remove every one; with -Position/-X/-Y, just the one there' }))
        $params.Add("[int]`$Size = $($Animation.Desktop.FontSize)"); $help.Add('.PARAMETER Size'); $help.Add('Font size; bigger makes the whole animation bigger')
        if ($Animation.Desktop.Layout -ne 'sky') {
            $params.Add("[ValidateSet($(($script:Positions | ForEach-Object { "'$_'" }) -join ', '))][string]`$Position = 'center'")
            $help.Add('.PARAMETER Position'); $help.Add('Where on the screen: center, a side, or a corner')
            $params.Add('[int]$X = -1'); $help.Add('.PARAMETER X'); $help.Add('Exact pixels from the left of the screen (overrides -Position)')
            $params.Add('[int]$Y = -1'); $help.Add('.PARAMETER Y'); $help.Add('Exact pixels from the top of the screen (overrides -Position)')
        }
    }

    foreach ($o in $Animation.Options) {
        $where = if ($o.Where) { $o.Where } else { 'both' }
        if ($where -ne 'both' -and $where -ne $Mode) { continue }
        $default = if ($defaults.ContainsKey($o.Name)) { $defaults[$o.Name] } else { $o.Default }
        $params.Add($(if ($o.Type -eq 'switch') { "[switch]`$$($o.Name)" } else { "[$($o.Type)]`$$($o.Name) = $(Format-Default $default)" }))
        $help.Add(".PARAMETER $($o.Name)")
        $help.Add($(if ($o.Type -eq 'switch') { $o.Help } else { "$($o.Help). Default: $default" }))
    }

    $help.Add('.EXAMPLE')
    $help.Add($(if ($Animation.Palettes) { "$command $($Animation.Palettes[-1])" } else { $command }))
    $invoke = if ($Mode -eq 'desktop') { 'Invoke-DesktopAnimation' } else { 'Invoke-TerminalAnimation' }
    # The variant's defaults first, then whatever was typed
    $preset = ($defaults.Keys | ForEach-Object { "$_ = $(Format-Default $defaults[$_])" }) -join '; '
    $body = "`$bound = @{ $preset }`nforeach (`$key in `$PSBoundParameters.Keys) { `$bound[`$key] = `$PSBoundParameters[`$key] }`n$invoke '$name' `$bound"
    "function $command {`n<#`n$($help -join "`n")`n#>`nparam(`n $($params -join ",`n ")`n)`n$body`n}"
}

$script:Commands = foreach ($animation in $script:Animations.Values) {
    foreach ($mode in 'terminal', 'desktop') {
        . ([scriptblock]::Create((New-CommandText $animation $mode)))
        if ($mode -eq 'desktop') { "desktop-$($animation.Name)" } else { $animation.Name }
    }
    foreach ($variant in $animation.Variants) {
        . ([scriptblock]::Create((New-CommandText $animation $variant.Mode $variant)))
        $variant.Command
    }
}

function asciifx {
    <#
    .SYNOPSIS
    Lists the AsciiFX animations and the commands that start them.
    #>

    $script:Animations.Values | ForEach-Object {
        $name = $_.Name
        $variants = @($_.Variants)
        [pscustomobject]@{
            Animation   = $name
            Terminal    = (@($name) + @($_.Palettes | Select-Object -Skip 1 | ForEach-Object { "$name $_" }) +
                           @($variants | Where-Object { $_.Mode -eq 'terminal' } | ForEach-Object Command)) -join ', '
            Desktop     = (@("desktop-$name") + @($variants | Where-Object { $_.Mode -eq 'desktop' } | ForEach-Object Command)) -join ', '
            Description = $_.Description
        }
    } | Format-Table -AutoSize -Wrap
    'Options for any of them: Get-Help <command> -Detailed Remove every desktop animation: desktop-clear'
}

function desktop-clear {
    <#
    .SYNOPSIS
    Removes every AsciiFX animation from the desktop.
    #>

    if (Test-Windows) { Stop-DesktopAnimation }
}

#endregion

Export-ModuleMember -Function (@('asciifx', 'desktop-clear') + $script:Commands)