Degauss.psm1
|
# Degauss: vintage computer looks for Windows Terminal. Installs the fonts (current user only, no admin # needed) and a Windows Terminal fragment with one hidden profile per look (only a profile can set a tab's # font), so the user's settings.json is never touched. The looks are used through two commands: # Set-DegaussLook (look) opens a tab with a look, in the current folder, and closes the old one # Set-DegaussColor (color) recolors the current tab, like DOS's COLOR command # and, for fun, Invoke-Degauss (degauss), which does what a CRT's degauss button did. # Works on Windows PowerShell 5.1 and PowerShell 7. $script:ModuleRoot = $PSScriptRoot # Where things get installed. Script variables so the tests can point them at a sandbox. $script:FontDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\Fonts' $script:FontKey = 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Fonts' # Never rename this folder: Windows Terminal ties users' profile settings to it. $script:FragmentDir = Join-Path $env:LOCALAPPDATA 'Microsoft\Windows Terminal\Fragments\Degauss' # Preferences (terminal.json: default look, default colors) and the color handed to a tab `look` opens. $script:DataDir = Join-Path $env:LOCALAPPDATA 'Degauss' # The Degauss VS Code extension installs the same fonts. Each project that uses them leaves a marker # file here; the fonts are only removed once no marker is left. $script:FontUsersDir = Join-Path $script:DataDir 'font-users' $script:Marker = 'powershell' $script:UserNames = @{ vscode = 'the Degauss VS Code extension' } # Installed font files start with this; matches installedFile in fonts.json (see tools/build.mjs). $script:FontFilePrefix = 'Degauss-' # The tests replace this with a fake that records its arguments. $script:WtCommand = 'wt.exe' # The one visible profile, "Degauss": a copy of the user's default look. Its GUID never changes, so it # can be Windows Terminal's default profile and stay that way when the default look changes. $script:DegaussProfileGuid = '{79eea499-748d-4441-821d-451f0c283c2e}' $script:DegaussProfileName = 'Degauss' # The look `look` opens when nothing else was chosen. $script:FallbackLook = 'apple2e' # Asks the user a yes/no question (default yes). The tests replace it with a scripted answer. $script:AskUser = { param([string]$Question) $choices = [System.Management.Automation.Host.ChoiceDescription[]]@('&Yes', '&No') return $Host.UI.PromptForChoice('Degauss', $Question, $choices, 0) -eq 0 } $Esc = [char]27 $St = "$Esc\" # String Terminator, ends an OSC sequence # ---------- data shipped with the module ---------- # Windows PowerShell 5.1's ConvertFrom-Json emits a JSON array as one object; these emit items one by one. function Get-DegaussFont { $fonts = Get-Content (Join-Path $script:ModuleRoot 'fonts.json') -Raw | ConvertFrom-Json foreach ($font in $fonts) { $font } } function Get-LookData { $looks = Get-Content (Join-Path $script:ModuleRoot 'looks.json') -Raw | ConvertFrom-Json foreach ($look in $looks) { $look } } function Get-PaletteData { Get-Content (Join-Path $script:ModuleRoot 'palette.json') -Raw | ConvertFrom-Json } # ---------- fonts ---------- # Pixel fonts are only sharp when each font pixel covers a whole number of screen pixels # (screen pixels = points * dpi / 72). Pick the sharp size closest to the nominal one. function Get-SharpPoints([double]$Points, [int]$PixelsPerEm, [int]$Dpi) { $multiple = [math]::Max(1, [math]::Round($Points * $Dpi / 72 / $PixelsPerEm, [MidpointRounding]::AwayFromZero)) return [math]::Round($multiple * $PixelsPerEm * 72 / $Dpi, 3) } function Get-DisplayDpi { $metrics = Get-ItemProperty 'HKCU:\Control Panel\Desktop\WindowMetrics' -Name AppliedDPI -ErrorAction SilentlyContinue if ($metrics -and $metrics.AppliedDPI -gt 0) { return [int]$metrics.AppliedDPI } return 96 } function Install-DegaussFont { New-Item -ItemType Directory -Force $script:FontDir | Out-Null if (-not (Test-Path $script:FontKey)) { New-Item $script:FontKey -Force | Out-Null } foreach ($font in Get-DegaussFont) { $source = Join-Path $script:ModuleRoot "fonts\$($font.id)\$($font.file)" $dest = Join-Path $script:FontDir $font.installedFile # Installed fonts may be locked by running apps; identical files don't need copying. if (-not ((Test-Path $dest) -and (Get-Item $dest).Length -eq (Get-Item $source).Length)) { Copy-Item $source $dest -Force } Set-ItemProperty -Path $script:FontKey -Name $font.registryName -Value $dest Write-Host "Installed font: $($font.family)" } } # Removes every Degauss font, including ones from older versions, by their file name prefix. function Uninstall-DegaussFont { if (-not (Test-Path $script:FontKey)) { return } $entries = (Get-ItemProperty $script:FontKey).PSObject.Properties | Where-Object { $_.Value -is [string] -and (Split-Path $_.Value -Leaf) -like "$script:FontFilePrefix*" } foreach ($entry in $entries) { Remove-ItemProperty -Path $script:FontKey -Name $entry.Name try { Remove-Item $entry.Value -Force -ErrorAction Stop Write-Host "Removed font: $($entry.Name)" } catch { Write-Warning "Font file is in use, so it stays until you delete it later (it's no longer registered): $($entry.Value)" } } } function Add-FontMarker { New-Item -ItemType Directory -Force $script:FontUsersDir | Out-Null $version = $ExecutionContext.SessionState.Module.Version $line = "Degauss for Windows Terminal (Degauss module $version), $((Get-Date).ToUniversalTime().ToString('o'))" [IO.File]::WriteAllText((Join-Path $script:FontUsersDir $script:Marker), "$line`n") } function Remove-FontMarker { Remove-Item (Join-Path $script:FontUsersDir $script:Marker) -Force -ErrorAction SilentlyContinue # Leave nothing behind once no project uses the fonts. foreach ($dir in $script:FontUsersDir, $script:DataDir) { if ((Test-Path $dir) -and -not (Get-ChildItem $dir -Force)) { Remove-Item $dir -Force } } } # The other projects still using the fonts (marker names, e.g. "vscode"). function Get-OtherFontUser { if (-not (Test-Path $script:FontUsersDir)) { return } Get-ChildItem $script:FontUsersDir -File | Where-Object Name -ne $script:Marker | ForEach-Object Name } # ---------- colors (the same math as vscode/palette.js; the tests compare the two) ---------- function ConvertTo-Rgb([string]$Hex) { $n = [Convert]::ToInt32($Hex.TrimStart('#'), 16) return @((($n -shr 16) -band 255), (($n -shr 8) -band 255), ($n -band 255)) } function ConvertTo-HexColor([double[]]$Rgb) { $parts = foreach ($c in $Rgb) { # [double] literals: with integer 0 and 255, PowerShell picks the integer overloads of Min/Max, # which round 250.5 to 250 before we get to round it. $clamped = [math]::Min([double]255, [math]::Max([double]0, $c)) ([int][math]::Round($clamped, [MidpointRounding]::AwayFromZero)).ToString('X2') } return '#' + ($parts -join '') } function Get-MixedColor([string]$From, [string]$To, [double]$T) { $a = ConvertTo-Rgb $From $b = ConvertTo-Rgb $To return ConvertTo-HexColor @(($a[0] + ($b[0] - $a[0]) * $T), ($a[1] + ($b[1] - $a[1]) * $T), ($a[2] + ($b[2] - $a[2]) * $T)) } # A preset name or #RRGGBB (# optional) -> '#RRGGBB', brightened like a glowing phosphor; $null if invalid. function Resolve-DegaussColor([string]$Value) { if (-not $Value) { return $null } $data = Get-PaletteData $name = $Value.Trim().ToLower() $preset = $data.presets.PSObject.Properties[$name] if ($preset) { return $preset.Value } if ($name -notmatch '^#?([0-9a-f]{6})$') { return $null } $rgb = ConvertTo-Rgb $Matches[1] $peak = [math]::Max($rgb[0], [math]::Max($rgb[1], $rgb[2])) if ($peak -eq 0) { return $null } if ($peak -lt $data.minPeak) { return ConvertTo-HexColor @(($rgb[0] * $data.minPeak / $peak), ($rgb[1] * $data.minPeak / $peak), ($rgb[2] * $data.minPeak / $peak)) } return ConvertTo-HexColor $rgb } function Get-PhosphorPalette([string]$Color) { $dark = { param($t) Get-MixedColor '#000000' $Color $t } $light = { param($t) Get-MixedColor $Color '#FFFFFF' $t } $palette = @{ deep = & $dark 0.055; bg = & $dark 0.07; raised = & $dark 0.118; faint = & $dark 0.165; border = & $dark 0.227 selection = & $dark 0.36; dim = & $dark 0.42; comment = & $dark 0.54; muted = & $dark 0.7; soft = & $dark 0.815 text = & $dark 0.9; full = $Color light1 = & $light 0.2; light2 = & $light 0.38; light3 = & $light 0.54; light4 = & $light 0.7 normal = & $dark 0.72; bright = & $light 0.12 } foreach ($entry in (Get-PaletteData).vga.PSObject.Properties) { $rgb = ConvertTo-Rgb $entry.Value $level = (0.3 * $rgb[0] + 0.59 * $rgb[1] + 0.11 * $rgb[2]) / 255 $palette["vga_$($entry.Name)"] = if ($level -eq 0) { '#000000' } else { & $dark (0.25 + 0.75 * $level) } } return $palette } # The Terminal color scheme (background, foreground, the 16 ANSI colors, ...) of a style in a color. function Get-DegaussScheme([string]$Style, [string]$Color) { $palette = Get-PhosphorPalette $Color $template = Get-Content (Join-Path $script:ModuleRoot "templates\$Style-terminal-scheme.json") -Raw $filled = [regex]::Replace($template, '\$\{(\w+)\}', { param($m) $slot = $m.Groups[1].Value if (-not $palette.ContainsKey($slot)) { throw "Unknown palette slot $slot in $Style template." } $palette[$slot] }) $scheme = @{} foreach ($entry in ($filled | ConvertFrom-Json).PSObject.Properties) { $scheme[$entry.Name] = $entry.Value } return $scheme } # DOS's 16 colors, by COLOR command digit. $script:DosColors = @('#000000', '#0000AA', '#00AA00', '#00AAAA', '#AA0000', '#AA00AA', '#AA5500', '#AAAAAA', '#555555', '#5555FF', '#55FF55', '#55FFFF', '#FF5555', '#FF55FF', '#FFFF55', '#FFFFFF') # On a black background, a DOS color that matches a phosphor preset means that preset. $script:DosPresets = @{ 0xA = 'green'; 0xE = 'yellow'; 0xF = 'white'; 0xB = 'cyan'; 0x6 = 'amber' } # Parses what Set-DegaussColor accepts. Returns @{ Stored = <normalized text>; Phosphor = '#RRGGBB' } for a # phosphor color, or @{ Stored; Foreground; Background } for a DOS code with a background. function ConvertFrom-ColorSpec([string]$Spec) { $value = $Spec.Trim() $presets = (Get-PaletteData).presets.PSObject.Properties.Name if ($presets -contains $value.ToLower()) { return @{ Stored = $value.ToLower(); Phosphor = (Resolve-DegaussColor $value) } } if ($value -match '^#?[0-9a-fA-F]{6}$') { $color = Resolve-DegaussColor $value if (-not $color) { throw "'$Spec' is black; pick a color that glows." } return @{ Stored = '#' + $value.TrimStart('#').ToUpper(); Phosphor = $color } } if ($value -match '^[0-9a-fA-F]{1,2}$') { $code = $value.ToUpper().PadLeft(2, '0') $background = [Convert]::ToInt32($code.Substring(0, 1), 16) $foreground = [Convert]::ToInt32($code.Substring(1, 1), 16) if ($background -eq $foreground) { throw "COLOR ${code}: the text and background colors can't be the same (DOS agreed)." } if ($background -eq 0) { $phosphor = if ($script:DosPresets.ContainsKey($foreground)) { Resolve-DegaussColor $script:DosPresets[$foreground] } else { Resolve-DegaussColor $script:DosColors[$foreground] } return @{ Stored = $code; Phosphor = $phosphor } } return @{ Stored = $code; Foreground = $script:DosColors[$foreground]; Background = $script:DosColors[$background] } } throw "Unknown color '$Spec'. Use $($presets -join ', '), #RRGGBB, or a DOS code like 0A or 1F." } # ---------- terminal escape sequences ---------- $script:AnsiOrder = @('black', 'red', 'green', 'yellow', 'blue', 'purple', 'cyan', 'white', 'brightBlack', 'brightRed', 'brightGreen', 'brightYellow', 'brightBlue', 'brightPurple', 'brightCyan', 'brightWhite') function Format-OscColor([string]$Hex) { $h = $Hex.TrimStart('#').ToLower() return "rgb:$($h.Substring(0, 2))/$($h.Substring(2, 2))/$($h.Substring(4, 2))" } # The escape sequences that recolor a terminal: the 16 ANSI colors (OSC 4), text (OSC 10), background # (OSC 11) and cursor (OSC 12). Windows Terminal and VS Code's terminal both understand them. function Get-ColorSequence([hashtable]$Scheme) { $sequence = '' for ($i = 0; $i -lt 16; $i++) { $sequence += "$Esc]4;$i;$(Format-OscColor $Scheme[$script:AnsiOrder[$i]])$St" } $sequence += "$Esc]10;$(Format-OscColor $Scheme.foreground)$St" $sequence += "$Esc]11;$(Format-OscColor $Scheme.background)$St" $sequence += "$Esc]12;$(Format-OscColor $Scheme.cursorColor)$St" return $sequence } # Puts the tab back to its profile's own colors. $script:ResetSequence = "$Esc]104$St$Esc]110$St$Esc]111$St$Esc]112$St" # The sequence for a parsed color in a look (or, outside Degauss tabs, as a plain phosphor monitor). function Get-SpecSequence($Parsed, $Look) { if ($Parsed.Phosphor) { $style = if ($Look) { $Look.colorStyle } else { 'phosphor' } return Get-ColorSequence (Get-DegaussScheme $style $Parsed.Phosphor) } # A DOS code with a background: like DOS, only the text and background change. return "$Esc]10;$(Format-OscColor $Parsed.Foreground)$St$Esc]11;$(Format-OscColor $Parsed.Background)$St$Esc]12;$(Format-OscColor $Parsed.Foreground)$St" } function Write-TerminalSequence([string]$Sequence) { [Console]::Write($Sequence) } # Windows Terminal throws away colors set by programs when the input language changes (and in a few other # cases; microsoft/terminal#11522). So a tab remembers its color and re-sends it with every prompt: after # such a reset, the color is back as soon as a command runs or Enter is pressed. The user's own prompt # function is wrapped, not replaced, and Uninstall-Degauss puts it back. $script:TabSequence = $null $script:OriginalPrompt = $null $script:PromptWrapper = { if ($script:TabSequence) { try { Write-TerminalSequence $script:TabSequence } catch { } } & $script:OriginalPrompt } function Set-TabColorState([string]$Sequence) { $script:TabSequence = $Sequence if ($Sequence -and -not $script:OriginalPrompt) { $current = Get-Command prompt -CommandType Function -ErrorAction SilentlyContinue $script:OriginalPrompt = if ($current) { $current.ScriptBlock } else { { "PS $($executionContext.SessionState.Path.CurrentLocation)$('>' * ($nestedPromptLevel + 1)) " } } Set-Item function:global:prompt -Value $script:PromptWrapper } } function Remove-PromptHook { if ($script:OriginalPrompt) { Set-Item function:global:prompt -Value $script:OriginalPrompt $script:OriginalPrompt = $null } $script:TabSequence = $null } # ---------- preferences ---------- # defaultLook: what `look` and the Degauss profile open; colors: each look's default color; # showProfiles, keepFontSizes: Install-Degauss's choices, kept for when the fragment is rewritten. function Get-DegaussPreference { $prefs = @{ defaultLook = $null; colors = @{}; showProfiles = $false; keepFontSizes = $false; installedVersion = $null } $file = Join-Path $script:DataDir 'terminal.json' if (Test-Path $file) { $saved = Get-Content $file -Raw | ConvertFrom-Json if ($saved.defaultLook) { $prefs.defaultLook = $saved.defaultLook } if ($saved.colors) { foreach ($entry in $saved.colors.PSObject.Properties) { $prefs.colors[$entry.Name] = $entry.Value } } $prefs.showProfiles = [bool]$saved.showProfiles $prefs.keepFontSizes = [bool]$saved.keepFontSizes if ($saved.installedVersion) { $prefs.installedVersion = $saved.installedVersion } } return $prefs } function Save-DegaussPreference([hashtable]$Prefs) { New-Item -ItemType Directory -Force $script:DataDir | Out-Null $json = [pscustomobject]@{ defaultLook = $Prefs.defaultLook; colors = [pscustomobject]$Prefs.colors showProfiles = [bool]$Prefs.showProfiles; keepFontSizes = [bool]$Prefs.keepFontSizes installedVersion = $Prefs.installedVersion } | ConvertTo-Json -Depth 5 [IO.File]::WriteAllText((Join-Path $script:DataDir 'terminal.json'), $json, (New-Object Text.UTF8Encoding $false)) } function Get-ModuleVersion { "$($ExecutionContext.SessionState.Module.Version)" } # Is Windows Terminal installed? A script variable so the tests can pretend either way. $script:IsTerminalInstalled = { [bool](Get-Command wt.exe -ErrorAction SilentlyContinue) } # Can PowerShell 7 load this version of the module? Install-Module installs for the PowerShell that runs it # only, so a module installed from Windows PowerShell is usually missing from PowerShell 7. A script variable # so the tests can pretend either way. $script:PwshHasModule = { if ($PSVersionTable.PSEdition -eq 'Core') { return $true } if (-not (Get-Command pwsh.exe -ErrorAction SilentlyContinue)) { return $false } $check = "if (Get-Module -ListAvailable Degauss | Where-Object { `"`$(`$_.Version)`" -eq '$(Get-ModuleVersion)' }) { exit 0 } else { exit 1 }" & pwsh.exe -NoProfile -NonInteractive -Command $check 2>$null | Out-Null return $LASTEXITCODE -eq 0 } # The PowerShell Degauss tabs run: PowerShell 7 if it has this module, otherwise Windows PowerShell. function Get-TabShell { if (& $script:PwshHasModule) { 'pwsh.exe' } else { 'powershell.exe' } } function Write-NotInTerminal { if (& $script:IsTerminalInstalled) { Write-Host "look switches Windows Terminal tabs to a retro look, and this terminal isn't Windows Terminal. Open Windows Terminal and run look there. (color works here too, if this terminal supports it.)" } else { Write-Host "look needs Windows Terminal, which isn't installed. Get it from the Microsoft Store, or with: winget install Microsoft.WindowsTerminal" } } # `look` needs the fonts and the Terminal profiles. Installing a module never runs its code, so on first use # this offers to set them up, and after an update it offers to refresh them. Returns $true when `look` can # go ahead right away. function Confirm-DegaussSetup { if (-not (Test-Path (Join-Path $script:FragmentDir 'degauss.json'))) { $question = "Degauss isn't set up yet: it needs to install its fonts (for your user only) and add its profiles to Windows Terminal. Set it up now?" if (-not (& $script:AskUser $question)) { Write-Host 'Not set up. Run Install-Degauss whenever you are ready.' return $false } Install-Degauss Write-Host 'Restart Windows Terminal (close all its windows), then run look again.' -ForegroundColor Yellow return $false } $prefs = Get-DegaussPreference $current = Get-ModuleVersion if ($prefs.installedVersion -ne $current) { $from = if ($prefs.installedVersion) { $prefs.installedVersion } else { 'an earlier version' } if (& $script:AskUser "Degauss was updated ($from to $current). Refresh its fonts and Windows Terminal profiles?") { Install-Degauss -ShowProfiles:$prefs.showProfiles -KeepFontSizes:$prefs.keepFontSizes Write-Host 'Looks added in this update appear after you restart Windows Terminal.' -ForegroundColor Yellow } } return $true } function Get-DefaultLookId([hashtable]$Prefs) { if ($Prefs.defaultLook -and (Find-DegaussLook $Prefs.defaultLook)) { return $Prefs.defaultLook } return $script:FallbackLook } # Writes the Windows Terminal fragment: a hidden profile per look (what `look` opens) and the visible # Degauss profile, a copy of the default look. Terminal reads it when it starts. # The Terminal color scheme for a look's default color: one of the shipped preset schemes if it matches, # or else a new "<look> Custom" scheme. Returns @{ name; new } (new: the scheme to add, if any). function Get-DefaultColorScheme($Entry, [string]$Spec, [string]$CurrentScheme, $Schemes) { $parsed = ConvertFrom-ColorSpec $Spec if ($parsed.Phosphor -and $Entry.monochrome) { foreach ($preset in (Get-PaletteData).presets.PSObject.Properties) { $name = "$($Entry.name) " + $preset.Name.Substring(0, 1).ToUpper() + $preset.Name.Substring(1) if ($preset.Value -eq $parsed.Phosphor -and ($Schemes | Where-Object { $_.name -eq $name })) { return @{ name = $name; new = $null } } } } $custom = [ordered]@{ name = "$($Entry.name) Custom" } if ($parsed.Phosphor) { $colors = Get-DegaussScheme $Entry.colorStyle $parsed.Phosphor foreach ($key in $colors.Keys | Sort-Object) { $custom[$key] = $colors[$key] } } else { # A DOS code with a background: the look's own colors, with DOS's text and background. $base = $Schemes | Where-Object { $_.name -eq $CurrentScheme } foreach ($p in $base.PSObject.Properties) { if ($p.Name -ne 'name') { $custom[$p.Name] = $p.Value } } $custom.foreground = $parsed.Foreground $custom.cursorColor = $parsed.Foreground $custom.background = $parsed.Background } return @{ name = $custom.name; new = [pscustomobject]$custom } } function Write-DegaussFragment([hashtable]$Prefs) { $shell = Get-TabShell $pixelsPerEm = @{} foreach ($font in Get-DegaussFont) { if ($font.pixelsPerEm) { $pixelsPerEm[$font.family] = [int]$font.pixelsPerEm } } $fragment = Get-Content (Join-Path $script:ModuleRoot 'degauss.json') -Raw | ConvertFrom-Json $dpi = Get-DisplayDpi $schemes = @($fragment.schemes) $looksByGuid = @{} foreach ($entry in Get-LookData) { $looksByGuid[$entry.guid] = $entry } foreach ($terminalProfile in $fragment.profiles) { # Each tab applies its saved color (and a color handed over by `look -Color`) when it opens. $terminalProfile | Add-Member -NotePropertyName commandline -NotePropertyValue "$shell -NoLogo -NoExit -Command Initialize-DegaussTab" -Force $terminalProfile.hidden = -not $Prefs.showProfiles $grid = $pixelsPerEm[$terminalProfile.font.face] if ($grid -and -not $Prefs.keepFontSizes) { $terminalProfile.font.size = Get-SharpPoints $terminalProfile.font.size $grid $dpi } # Windows Terminal resets colors set by programs to the profile's scheme, e.g. when the input # language changes (microsoft/terminal#11522). So the profile uses the scheme of the look's default # color, and a reset lands on the user's color rather than the built-in one. $entry = $looksByGuid[$terminalProfile.guid] if ($entry -and $Prefs.colors[$entry.id]) { $scheme = Get-DefaultColorScheme $entry $Prefs.colors[$entry.id] $terminalProfile.colorScheme $schemes if ($scheme.new) { $schemes += $scheme.new } $terminalProfile.colorScheme = $scheme.name } } $fragment.schemes = $schemes $defaultLook = Find-DegaussLook (Get-DefaultLookId $Prefs) $source = $fragment.profiles | Where-Object { $_.guid -eq $defaultLook.guid } $visible = $source | ConvertTo-Json -Depth 5 | ConvertFrom-Json # a copy $visible.guid = $script:DegaussProfileGuid $visible.name = $script:DegaussProfileName $visible.hidden = $false # -Look tells the tab which look this copy was made from, so it can notice a newer default. $visible.commandline = "$shell -NoLogo -NoExit -Command Initialize-DegaussTab -Look $($defaultLook.id)" $fragment.profiles = @($visible) + @($fragment.profiles) New-Item -ItemType Directory -Force $script:FragmentDir | Out-Null $json = $fragment | ConvertTo-Json -Depth 10 [IO.File]::WriteAllText((Join-Path $script:FragmentDir 'degauss.json'), $json, (New-Object Text.UTF8Encoding $false)) } # ---------- looks ---------- function Find-DegaussLook([string]$Name) { $wanted = $Name.Trim().ToLower() foreach ($look in Get-LookData) { $names = @($look.id, $look.name) + @($look.aliases) | ForEach-Object { "$_".ToLower() } if ($names -contains $wanted) { return $look } } return $null } # The look of the current Windows Terminal tab: the one Initialize-DegaussTab recorded when the tab opened, # or else the one of the profile Terminal says the tab was opened with. function Get-CurrentLook { if ($env:TERM_PROGRAM -eq 'vscode') { return $null } # VS Code's terminal follows the VS Code look if ($env:DEGAUSS_LOOK) { $look = Find-DegaussLook $env:DEGAUSS_LOOK if ($look) { return $look } } if (-not $env:WT_PROFILE_ID) { return $null } $id = $env:WT_PROFILE_ID.Trim('{', '}').ToLower() foreach ($look in Get-LookData) { if ($look.guid.Trim('{', '}').ToLower() -eq $id) { return $look } } return $null } function Get-LookNames { foreach ($look in Get-LookData) { @($look.aliases)[0] } } # ---------- commands ---------- function Set-DegaussColor { <# .SYNOPSIS Recolors the current terminal tab, like DOS's COLOR command. Alias: color. .DESCRIPTION Takes a phosphor color (green, amber, white, cyan, yellow or #RRGGBB) or a DOS COLOR code (0A, 1F, ...; the first digit is the background, the second the text). Only this tab changes. In a Degauss tab the color is applied the way that machine would show it; for example an IBM 3270 tab keeps its two brightness levels. Without a color, the tab goes back to its default colors. .PARAMETER Color A preset (green, amber, white, cyan, yellow), #RRGGBB, or a DOS code. .PARAMETER SetAsDefault Also make this the color new tabs of this look open with. Without -Color, go back to the look's own default. .EXAMPLE color amber .EXAMPLE color 0A .EXAMPLE color '#40E0FF' -SetAsDefault #> [CmdletBinding()] param( [Parameter(Position = 0)][string]$Color, [switch]$SetAsDefault ) $look = Get-CurrentLook if ($SetAsDefault -and -not $look) { throw "-SetAsDefault works in a Degauss tab (open one with Set-DegaussLook, alias look)." } $prefs = Get-DegaussPreference if (-not $Color) { if ($SetAsDefault) { $prefs.colors.Remove($look.id) Save-DefaultColor $prefs $look } Write-TerminalSequence $script:ResetSequence $sequence = $null if ($look -and $prefs.colors[$look.id]) { $sequence = Get-SpecSequence (ConvertFrom-ColorSpec $prefs.colors[$look.id]) $look Write-TerminalSequence $sequence } Set-TabColorState $sequence return } $parsed = ConvertFrom-ColorSpec $Color $sequence = Get-SpecSequence $parsed $look if (-not $parsed.Phosphor) { $sequence = $script:ResetSequence + $sequence } Write-TerminalSequence $sequence Set-TabColorState $sequence if ($SetAsDefault) { $prefs.colors[$look.id] = $parsed.Stored Save-DefaultColor $prefs $look Write-Host "New $($look.name) tabs will open in $($parsed.Stored)." } } # Saves a look's default color, and puts it in the look's Windows Terminal profile too, so that Terminal's # own color resets land on it (after Terminal restarts and reads the profile again). function Save-DefaultColor([hashtable]$Prefs, $Look) { Save-DegaussPreference $Prefs if (Test-Path (Join-Path $script:FragmentDir 'degauss.json')) { Write-DegaussFragment $Prefs Write-Host "Restart Windows Terminal for $($Look.name)'s profile to use it too (so Terminal's own color resets, e.g. when you switch input languages, return to it)." } } # ---------- degauss ---------- # Replaceable by the tests. $script:PlaySound = { param($File) $script:Player = New-Object System.Media.SoundPlayer $File $script:Player.Play() # plays in the background while the colors swirl } $script:DegaussFrames = 32 $script:DegaussFrameMs = 40 # The widest wobble, in columns, is twice this (vscode/degauss.js wobbles by pixels, a terminal by cells). $script:DegaussWobbleColumns = 2 # The characters and colors on screen, as a BufferCell[,]. Throws where the host can't read the screen # (then degauss only swirls the colors). Replaceable by the tests. $script:GetScreenCells = { $raw = $Host.UI.RawUI $position = $raw.WindowPosition $size = $raw.WindowSize if ($size.Width -le 0 -or $size.Height -le 0) { throw 'No screen to read.' } $rectangle = New-Object System.Management.Automation.Host.Rectangle $position.X, $position.Y, ($position.X + $size.Width - 1), ($position.Y + $size.Height - 1) , $raw.GetBufferContents($rectangle) } # The scheme a Degauss tab opened with: its profile's, in the module's copy of the fragment. function Get-LookScheme($Look) { $fragment = Get-Content (Join-Path $script:ModuleRoot 'degauss.json') -Raw | ConvertFrom-Json $name = $null foreach ($p in $fragment.profiles) { if ($p.guid -eq $Look.guid) { $name = $p.colorScheme } } foreach ($entry in $fragment.schemes) { if ($entry.name -ne $name) { continue } $scheme = @{} foreach ($property in $entry.PSObject.Properties) { $scheme[$property.Name] = $property.Value } return $scheme } return $null } # The tab's current colors, as far as the module knows them: what `color` set, else the look's own, else # Windows Terminal's default (Campbell). @{ Ansi = 16 colors or $null; Foreground; Background; Cursor } function Get-TabColors { $sequence = $script:TabSequence if (-not $sequence) { $look = Get-CurrentLook $scheme = if ($look) { Get-LookScheme $look } else { $null } if ($scheme) { $sequence = Get-ColorSequence $scheme } } $colors = @{ Ansi = $null; Foreground = '#CCCCCC'; Background = '#0C0C0C'; Cursor = '#FFFFFF' } if (-not $sequence) { return $colors } $ansi = New-Object 'string[]' 16 foreach ($m in [regex]::Matches($sequence, '\](4;(\d+)|10|11|12);rgb:(\w\w)/(\w\w)/(\w\w)')) { $hex = '#' + ($m.Groups[3].Value + $m.Groups[4].Value + $m.Groups[5].Value).ToUpper() switch ($m.Groups[1].Value) { '10' { $colors.Foreground = $hex } '11' { $colors.Background = $hex } '12' { $colors.Cursor = $hex } default { $ansi[[int]$m.Groups[2].Value] = $hex; $colors.Ansi = $ansi } } } return $colors } # Rotates a color's hue (the matrix of CSS's hue-rotate filter, as in vscode/degauss.js). function Get-RotatedColor([string]$Hex, [double]$Degrees) { $rgb = ConvertTo-Rgb $Hex $c = [math]::Cos($Degrees * [math]::PI / 180) $s = [math]::Sin($Degrees * [math]::PI / 180) return ConvertTo-HexColor @( ($rgb[0] * (0.213 + 0.787 * $c - 0.213 * $s) + $rgb[1] * (0.715 - 0.715 * $c - 0.715 * $s) + $rgb[2] * (0.072 - 0.072 * $c + 0.928 * $s)), ($rgb[0] * (0.213 - 0.213 * $c + 0.143 * $s) + $rgb[1] * (0.715 + 0.285 * $c + 0.14 * $s) + $rgb[2] * (0.072 - 0.072 * $c - 0.283 * $s)), ($rgb[0] * (0.213 - 0.213 * $c - 0.787 * $s) + $rgb[1] * (0.715 - 0.715 * $c + 0.715 * $s) + $rgb[2] * (0.072 + 0.928 * $c + 0.072 * $s))) } # A fully saturated color of the given hue. function Get-RainbowColor([double]$Hue) { $h = ((($Hue % 360) + 360) % 360) / 60 $x = 1 - [math]::Abs(($h % 2) - 1) $rgb = @(@(1, $x, 0), @($x, 1, 0), @(0, 1, $x), @(0, $x, 1), @($x, 0, 1), @(1, 0, $x))[[int][math]::Floor($h)] return ConvertTo-HexColor @(($rgb[0] * 255), ($rgb[1] * 255), ($rgb[2] * 255)) } # One frame of the effect at time $T (0 to 1): the colors swing around the color wheel and the background # is washed with a sweeping rainbow tint, both dying away like the coil's current (vscode/degauss.js). function Get-DegaussSequence($Colors, [double]$T) { $strength = (1 - $T) * (1 - $T) $hue = 240 * $strength * [math]::Sin(2 * [math]::PI * 4 * $T) $tint = Get-RainbowColor (720 * $T) $sequence = '' if ($Colors.Ansi) { for ($i = 0; $i -lt 16; $i++) { if ($Colors.Ansi[$i]) { $sequence += "$Esc]4;$i;$(Format-OscColor (Get-RotatedColor $Colors.Ansi[$i] $hue))$St" } } } $sequence += "$Esc]10;$(Format-OscColor (Get-RotatedColor $Colors.Foreground $hue))$St" $sequence += "$Esc]11;$(Format-OscColor (Get-MixedColor $Colors.Background $tint (0.3 * $strength)))$St" $sequence += "$Esc]12;$(Format-OscColor (Get-RotatedColor $Colors.Cursor $hue))$St" return $sequence } # Console colors (the order of [ConsoleColor]) as ANSI color numbers (the order of SGR 30-37 and 90-97). $script:ConsoleToAnsi = @(0, 4, 2, 6, 1, 5, 3, 7, 8, 12, 10, 14, 9, 13, 11, 15) function Get-SgrColor([int]$Color, [bool]$Background) { # Gray text on black is how the console reports the terminal's default colors. if ($Background -and $Color -eq 0) { return 49 } if (-not $Background -and $Color -eq 7) { return 39 } $ansi = $script:ConsoleToAnsi[$Color] $base = if ($ansi -lt 8) { 30 } else { 90 } # 30-37, or 90-97 for the bright ones if ($Background) { $base += 10 } return $base + ($ansi % 8) } # The screen as lines of text with SGR color sequences, for redrawing it. Trailing blanks are dropped. function ConvertFrom-ScreenCells($Cells) { $height = $Cells.GetLength(0) $width = $Cells.GetLength(1) $lines = New-Object 'string[]' $height for ($y = 0; $y -lt $height; $y++) { # The last cell that isn't a blank on the default background. $last = -1 for ($x = $width - 1; $x -ge 0; $x--) { $cell = $Cells[$y, $x] if (($cell.Character -ne ' ' -and $cell.Character -ne [char]0) -or [int]$cell.BackgroundColor -ne 0) { $last = $x; break } } $line = New-Object System.Text.StringBuilder $colors = -1 # the current foreground and background, as one number for ($x = 0; $x -le $last; $x++) { $cell = $Cells[$y, $x] if ($cell.BufferCellType -eq 'Trailing') { continue } # the right half of a wide character $next = 16 * [int]$cell.ForegroundColor + [int]$cell.BackgroundColor if ($next -ne $colors) { [void]$line.Append("$Esc[$(Get-SgrColor ([int]$cell.ForegroundColor) $false);$(Get-SgrColor ([int]$cell.BackgroundColor) $true)m") $colors = $next } $character = if ($cell.Character -eq [char]0) { ' ' } else { $cell.Character } [void]$line.Append($character) } if ($colors -ge 0) { [void]$line.Append("$Esc[0m") } $lines[$y] = $line.ToString() } return @{ Lines = $lines; Width = $width; Height = $height } } # One frame of the picture at time $T (0 to 1), redrawn on the alternate screen: each line shifted right by a # wave that dies away, like vscode/degauss.js's wobble. The first frames jolt down a line, like the picture # jumping when the coil fires. function Get-WobbleFrame($Screen, [double]$T, [int]$Frame) { $strength = (1 - $T) * (1 - $T) $jolt = if ($Frame -lt 2) { 1 } else { 0 } $out = New-Object System.Text.StringBuilder if ($jolt) { [void]$out.Append("$Esc[1;1H$Esc[0m$Esc[2K") } for ($y = 0; $y + $jolt -lt $Screen.Height; $y++) { $wave = $script:DegaussWobbleColumns * $strength * (1 + [math]::Sin(2 * [math]::PI * ($y / 10 + 5 * $T))) $offset = [int][math]::Floor($wave + 0.5) [void]$out.Append("$Esc[$($y + $jolt + 1);1H$Esc[0m$Esc[2K") if ($offset) { [void]$out.Append(' ' * $offset) } [void]$out.Append($Screen.Lines[$y]) } return $out.ToString() } # Onto the alternate screen (the real one, scrollback and all, is left untouched), cursor hidden, and no # wrapping, so lines shifted past the right edge are cut off. And back again. $script:EnterWobble = "$Esc[?1049h$Esc[?25l$Esc[?7l" $script:LeaveWobble = "$Esc[0m$Esc[?7h$Esc[?1049l$Esc[?25h" function Invoke-Degauss { <# .SYNOPSIS Degausses the terminal, like the button on a CRT monitor: a thunk, a hum, and a second of wobbling, swirling colors. Alias: degauss. .DESCRIPTION Works in any terminal tab. Where the screen can be read (Windows Terminal, the Windows console), the picture wobbles too; it's redrawn on the alternate screen, so nothing on the real one changes. Like the real thing, it also fixes the colors: afterwards the tab is back to its own colors, including ones Windows Terminal threw away (for example when you switch input languages). .PARAMETER Quiet Without the sound. .EXAMPLE degauss #> [CmdletBinding()] param([switch]$Quiet) $colors = Get-TabColors # The picture wobbles where the screen can be read; elsewhere only the colors swirl. $screen = $null try { $screen = ConvertFrom-ScreenCells (& $script:GetScreenCells) } catch { Write-Verbose "Colors only: $_" } # Worked out before the sound starts, so the frames keep up with it. $frames = for ($i = 0; $i -lt $script:DegaussFrames; $i++) { $t = $i / $script:DegaussFrames $frame = Get-DegaussSequence $colors $t if ($screen) { $frame += Get-WobbleFrame $screen $t $i } $frame } if (-not $Quiet) { try { & $script:PlaySound (Join-Path $script:ModuleRoot 'degauss.wav') } catch { Write-Verbose "No sound: $_" } } try { if ($screen) { Write-TerminalSequence $script:EnterWobble } foreach ($frame in $frames) { Write-TerminalSequence $frame if ($script:DegaussFrameMs) { Start-Sleep -Milliseconds $script:DegaussFrameMs } } } finally { if ($screen) { Write-TerminalSequence $script:LeaveWobble } Write-TerminalSequence $script:ResetSequence if ($script:TabSequence) { Write-TerminalSequence $script:TabSequence } } } function Initialize-DegaussTab { <# .SYNOPSIS Sets up a Degauss tab when it opens. The Degauss profiles run it; you don't need to. .PARAMETER Look The look the Degauss profile was made from (the other profiles are found by their GUID). #> [CmdletBinding()] param([string]$Look) $target = if ($Look) { Find-DegaussLook $Look } else { Get-CurrentLook } if (-not $target) { return } # Remember the look for `color`, which otherwise goes by the profile's GUID. $env:DEGAUSS_LOOK = $target.id # Windows Terminal reads the Degauss profile when it starts, so after a new default look it keeps # opening the old one until it's restarted. $prefs = Get-DegaussPreference if ($Look) { $default = Find-DegaussLook (Get-DefaultLookId $prefs) if ($default.id -ne $target.id) { Write-Host "Your default look is now $($default.name), but Windows Terminal still has $($target.name) loaded for the Degauss profile. Close all Windows Terminal windows and reopen it to switch." -ForegroundColor Yellow } } $spec = $null # A color handed over by `look -Color`, if it's for this look and fresh. $pending = Join-Path $script:DataDir 'pending-color' if (Test-Path $pending) { $parts = (Get-Content $pending -Raw).Trim() -split '\|' if ($parts[0] -eq $target.id -and ((Get-Date).ToUniversalTime().Ticks - [long]$parts[2]) -lt [TimeSpan]::FromMinutes(1).Ticks) { $spec = $parts[1] } Remove-Item $pending -Force -ErrorAction SilentlyContinue } if (-not $spec) { $spec = $prefs.colors[$target.id] } if ($spec) { $sequence = Get-SpecSequence (ConvertFrom-ColorSpec $spec) $target Write-TerminalSequence $sequence Set-TabColorState $sequence } } function Set-DegaussLook { <# .SYNOPSIS Opens a Windows Terminal tab with a retro look, in the current folder, and closes this one. Alias: look. .DESCRIPTION Windows Terminal only lets a profile set a tab's font, so a new tab opens with the look's (hidden) profile. Scrollback and anything running in the old tab are not carried over; use -KeepTab to keep it. Without a look, opens your default look (Apple //e unless you set another with -SetAsDefault). .PARAMETER Look The look: apple, ps2, ps2mono, 3270, 3270mono (or its full name). Tab completes. .PARAMETER Color Open the tab in this color: a preset, #RRGGBB or a DOS code (see Set-DegaussColor). .PARAMETER SetAsDefault Also make this look (and -Color, if given) your default: what `look` opens without arguments, and what the Degauss profile in Windows Terminal's menu opens (after a Windows Terminal restart). .PARAMETER Off Open a normal tab (your default Windows Terminal profile) instead. .PARAMETER KeepTab Don't close the current tab. .EXAMPLE look apple -Color amber .EXAMPLE look 3270 -SetAsDefault #> [CmdletBinding()] param( [Parameter(Position = 0)][string]$Look, [string]$Color, [switch]$SetAsDefault, [switch]$Off, [switch]$KeepTab ) if ($env:TERM_PROGRAM -eq 'vscode') { Write-Host "This terminal follows the VS Code look. Use 'Degauss: Choose Look...' in VS Code's Command Palette." return } # Only Windows Terminal can open a tab with a look. Elsewhere, explain (and still allow -SetAsDefault). $inTerminal = [bool]$env:WT_SESSION if (-not $inTerminal -and -not $SetAsDefault) { Write-NotInTerminal return } if ($inTerminal -and -not $Off -and -not (Confirm-DegaussSetup)) { return } $target = $null if (-not $Off) { $prefs = Get-DegaussPreference $name = $Look if (-not $name) { $name = Get-DefaultLookId $prefs } $target = Find-DegaussLook $name if (-not $target) { throw "Unknown look '$name'. Looks: $((Get-LookNames) -join ', ')." } $parsed = $null if ($Color) { $parsed = ConvertFrom-ColorSpec $Color } if ($SetAsDefault) { $previous = Get-DefaultLookId $prefs $prefs.defaultLook = $target.id if ($parsed) { $prefs.colors[$target.id] = $parsed.Stored } Save-DegaussPreference $prefs $installed = Test-Path (Join-Path $script:FragmentDir 'degauss.json') if ($installed) { Write-DegaussFragment $prefs } $color = $prefs.colors[$target.id] if (-not $color) { $color = $target.defaultColor } Write-Host "Default look: $($target.name)$(if ($color) { " ($color)" })." if ($installed -and $previous -ne $target.id) { Write-Host 'The Degauss profile in Windows Terminal switches to it after you restart Windows Terminal (close all its windows).' } } } if (-not $inTerminal) { return } # -SetAsDefault outside Windows Terminal: saved, nothing to open if ($target -and $parsed) { New-Item -ItemType Directory -Force $script:DataDir | Out-Null [IO.File]::WriteAllText((Join-Path $script:DataDir 'pending-color'), "$($target.id)|$($parsed.Stored)|$((Get-Date).ToUniversalTime().Ticks)") } $location = Get-Location $folder = if ($location.Provider.Name -eq 'FileSystem') { $location.ProviderPath } else { $HOME } $folder = $folder.Replace(';', '\;') # wt treats ; as a command separator $arguments = @('-w', '0', 'nt') if ($target) { $arguments += @('-p', $target.guid) } $arguments += @('-d', $folder) & $script:WtCommand @arguments if (-not $KeepTab) { exit } } function Get-DegaussLook { <# .SYNOPSIS Lists the looks, with the names Set-DegaussLook accepts and your default colors. #> [CmdletBinding()] param() $prefs = Get-DegaussPreference $current = Get-CurrentLook foreach ($look in Get-LookData) { $color = $prefs.colors[$look.id] if (-not $color) { $color = $look.defaultColor } [pscustomobject]@{ Name = $look.name Look = @($look.aliases)[0] Aliases = @($look.aliases) -join ', ' Color = $color Default = (Get-DefaultLookId $prefs) -eq $look.id Current = $current -and $current.id -eq $look.id } } } function Install-Degauss { <# .SYNOPSIS Installs the Degauss fonts and adds the looks to Windows Terminal. .DESCRIPTION Installs the fonts for the current user (no admin needed) and writes a Windows Terminal fragment: one visible profile, Degauss, which opens your default look (make it Windows Terminal's default profile if you like), and a hidden profile per look, which Set-DegaussLook (alias look) opens. Pixel fonts are sized for the display's scaling so they stay sharp. Restart Windows Terminal afterwards. Running it again updates an existing installation. .PARAMETER KeepFontSizes Use the profiles' nominal font sizes instead of the sharpest sizes for this display's scaling. .PARAMETER ShowProfiles Also show every look in Windows Terminal's profile menu. #> [CmdletBinding()] param([switch]$KeepFontSizes, [switch]$ShowProfiles) Install-DegaussFont Add-FontMarker $prefs = Get-DegaussPreference $prefs.showProfiles = [bool]$ShowProfiles $prefs.keepFontSizes = [bool]$KeepFontSizes $prefs.installedVersion = Get-ModuleVersion Save-DegaussPreference $prefs Write-DegaussFragment $prefs if (-not (& $script:IsTerminalInstalled)) { Write-Warning 'Windows Terminal is not installed, and the looks need it. The fonts are installed anyway. Get Windows Terminal from the Microsoft Store, or with: winget install Microsoft.WindowsTerminal' return } Write-Host '' Write-Host 'Degauss installed. Close all Windows Terminal windows and reopen it. Then open the' Write-Host "Degauss profile from Terminal's menu, or type, in any PowerShell tab:" Write-Host " look apple (or: $((Get-LookNames) -join ', '))" Write-Host ' color amber (or #RRGGBB, or a DOS code like 0A)' Write-Host 'Get-DegaussLook lists the looks.' } function Uninstall-Degauss { <# .SYNOPSIS Removes the looks from Windows Terminal, and the fonts unless something else still uses them. .PARAMETER RemoveFonts Remove the fonts even if the Degauss VS Code extension still uses them. #> [CmdletBinding()] param([switch]$RemoveFonts) if (Test-Path $script:FragmentDir) { Remove-Item $script:FragmentDir -Recurse -Force } foreach ($file in 'terminal.json', 'pending-color') { Remove-Item (Join-Path $script:DataDir $file) -Force -ErrorAction SilentlyContinue } Remove-PromptHook Write-Host 'Removed the looks from Windows Terminal.' Remove-FontMarker $others = @(Get-OtherFontUser) if ($others.Count -and -not $RemoveFonts) { $names = ($others | ForEach-Object { if ($script:UserNames[$_]) { $script:UserNames[$_] } else { $_ } }) -join ', ' Write-Host "Kept the fonts: $names still uses them (-RemoveFonts removes them anyway)." } else { Uninstall-DegaussFont } Write-Host 'Restart Windows Terminal to finish.' } # ---------- tab completion and aliases ---------- $lookCompleter = { param($commandName, $parameterName, $wordToComplete) Get-LookData | ForEach-Object { @($_.aliases)[0] } | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } } $colorCompleter = { param($commandName, $parameterName, $wordToComplete) (Get-PaletteData).presets.PSObject.Properties.Name | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) } } Register-ArgumentCompleter -CommandName Set-DegaussLook -ParameterName Look -ScriptBlock $lookCompleter Register-ArgumentCompleter -CommandName Set-DegaussLook -ParameterName Color -ScriptBlock $colorCompleter Register-ArgumentCompleter -CommandName Set-DegaussColor -ParameterName Color -ScriptBlock $colorCompleter Set-Alias -Name look -Value Set-DegaussLook Set-Alias -Name color -Value Set-DegaussColor Set-Alias -Name degauss -Value Invoke-Degauss Export-ModuleMember -Function Install-Degauss, Uninstall-Degauss, Set-DegaussLook, Set-DegaussColor, Get-DegaussLook, Initialize-DegaussTab, Invoke-Degauss -Alias look, color, degauss |