Public/Install-TerminalColorsTheme.ps1
|
# Installs the Windows Terminal theme that makes the tab and the title bar follow # the active pane's background colour. # # The surgical settings.json editing helpers live in Private\WtSettings.ps1, # shared with Install-TerminalColorsBackdrop and Install-TerminalColorsTitleBar. $script:TcThemeName = 'TerminalColors' function Get-TcSelectedTheme { <# .SYNOPSIS Raw value of the root "theme" setting, exactly as written: a quoted name, or the { "dark": ..., "light": ... } object form. $null when absent. #> param([string] $Text) $masked = Get-TcMaskedJson -Text $Text $m = [regex]::Match($masked, '"theme"\s*:\s*(?:"[^"]*"|\{[^{}]*\})') if (-not $m.Success) { return $null } $vm = [regex]::Match($Text.Substring($m.Index, $m.Length), '"theme"\s*:\s*(.+)$', 'Singleline') if ($vm.Success) { return $vm.Groups[1].Value.Trim() } return $null } function Get-TcApplicationTheme { <# .SYNOPSIS The window.applicationTheme a replacement theme must declare to leave the chrome as it was. .DESCRIPTION Selecting a theme replaces the previous one wholesale, so whatever it does not declare is lost. The tab row is painted from the chrome, which means dropping applicationTheme would flip the strip on a machine whose Windows is in the other mode - which is exactly what "system" did on a Windows set to light. The built-in dark and light themes are explicit. Anything else - "system", the { dark, light } object form, a custom theme, or nothing at all - follows Windows, which is Windows Terminal's own default. #> param([string] $ThemeValue) if ([string]::IsNullOrWhiteSpace($ThemeValue)) { return 'system' } if ($ThemeValue -match '^"?(dark|legacyDark)"?$') { return 'dark' } if ($ThemeValue -match '^"?(light|legacyLight)"?$') { return 'light' } return 'system' } function Get-TcThemeJson { <# .SYNOPSIS The theme document. [tab] follows the project; the tab row is left to Windows Terminal unless a colour is explicitly asked for - see the comment below, the asymmetry is the whole point. #> param( [string] $Indent = ' ', [string] $TabRowColor, [ValidateSet('system', 'light', 'dark')] [string] $ApplicationTheme = 'system' ) # How Windows Terminal actually paints a tab, measured rather than assumed: # # - the SELECTED tab is painted with its own background colour, opaquely. # A #215732 project gives exactly #215732. # - a BACKGROUND tab is composited at roughly 30 % opacity over the tab row. # Its own colour still shows through, but mixed with the row's. # # Hence the asymmetry below. [tab] uses terminalBackground, the only per-tab # value the theme format offers, so every tab carries its own project colour. # # The row must NOT be terminalBackground: the strip would take the colour of # whichever project is in front, and since it is the base every background tab # is composited over, a plain black tab next to a #215732 project came out # #1A4026 and a #61DAFB one came out #347E6E - everything turned green. # # Any stable colour satisfies that, so the row is simply left out rather than # pinned: Windows Terminal then paints it from its own chrome, exactly as its # built-in themes do - light, dark and system declare no tabRow.background # either. The strip therefore looks the same whether this module is installed # or not, which is the point. -TabRowColor pins it for anyone who does want a # specific colour, and window.applicationTheme carries the chrome's light/dark # identity over from the theme being replaced. # # window.frame is what actually colours the window border. DWM is not: # DwmSetWindowAttribute(DWMWA_BORDER_COLOR) returns S_OK on a Windows Terminal # window and changes nothing at all - measured, colour applied straight from a # test process with nothing else in between. Windows Terminal draws its own # frame, so the theme is the only way in, and terminalBackground makes it # follow the project exactly like the tab does. $lines = @( '{', ' "name": "TerminalColors",', ' "tab":', ' {', ' "background": "terminalBackground",', ' "unfocusedBackground": "terminalBackground"', ' },' ) if ($TabRowColor) { $lines += @( ' "tabRow":', ' {', (' "background": "' + $TabRowColor + '",'), (' "unfocusedBackground": "' + $TabRowColor + '"'), ' },' ) } $lines += @( ' "window":', ' {', (' "applicationTheme": "' + $ApplicationTheme + '",'), ' "frame": "terminalBackground",', ' "unfocusedFrame": "terminalBackground"', ' }', '}' ) return ($lines -join ([Environment]::NewLine + $Indent + $Indent)) } function Test-TcThemeInstalled { param([string] $Text) $masked = Get-TcMaskedJson -Text $Text return [regex]::IsMatch($masked, '"name"\s*:\s*"TerminalColors"') } function Test-TcThemeInSettings { <# .SYNOPSIS Whether the theme is present in the settings.json on disk. .DESCRIPTION Test-TcThemeInstalled works on text, which suits the install path since it already holds the document. A caller that only wants to know whether there is anything to remove should not have to read the file itself. #> [CmdletBinding()] [OutputType([bool])] param([string] $SettingsPath) if (-not $SettingsPath) { try { $SettingsPath = Get-TcWtSettingsPath } catch { return $false } } if (-not $SettingsPath -or -not [System.IO.File]::Exists($SettingsPath)) { return $false } return (Test-TcThemeInstalled -Text ([System.IO.File]::ReadAllText($SettingsPath))) } function Test-TcThemeUpToDate { <# .SYNOPSIS Tells whether an installed theme is a working shape: every tab follows its own project, the tab row does not, and the border is coloured. A theme left over from an older version of the module fails this check, and Install-TerminalColorsTheme then replaces it without the caller having to pass -Force. Only the two broken shapes count: terminalBackground on the row (background tabs borrow the active project's colour) and a missing window.frame (nothing colours the border). A row pinned to a fixed colour is not broken, just a deliberate choice, so it passes: -TabRowColor must survive a plain reinstall. #> param($Settings) $theme = $Settings.themes | Where-Object { $_.name -eq $script:TcThemeName } | Select-Object -First 1 if ($null -eq $theme) { return $false } $tab = Get-TcJsonProperty -InputObject $theme -Name 'tab' $tabRow = Get-TcJsonProperty -InputObject $theme -Name 'tabRow' $window = Get-TcJsonProperty -InputObject $theme -Name 'window' $tabUnfocused = [string](Get-TcJsonProperty -InputObject $tab -Name 'unfocusedBackground') $rowBackground = [string](Get-TcJsonProperty -InputObject $tabRow -Name 'background') $frame = [string](Get-TcJsonProperty -InputObject $window -Name 'frame') return (($tabUnfocused -eq 'terminalBackground') -and ($rowBackground -ne 'terminalBackground') -and ($frame -eq 'terminalBackground')) } function Install-TerminalColorsTheme { <# .SYNOPSIS Installs the [TerminalColors] theme into Windows Terminal and selects it. .DESCRIPTION This theme is what makes the colouring visible: it tells Windows Terminal to paint the tab and the window border with the active pane's background colour. Since the module changes that background colour on every directory change, both follow automatically. The tab row - the strip the tabs sit in - is deliberately left out of that, and left alone entirely: Windows Terminal paints it from its own chrome, as it does without this module, so installing changes nothing about it. settings.json is modified by targeted insertion, with a backup taken first and the result validated before writing. .PARAMETER SettingsPath Path of the settings.json to modify. Detected automatically by default (Store, Preview, unpackaged and portable versions). .PARAMETER TabRowColor Pins the tab row - the strip the tabs sit in, which is also the title bar when the tabs are drawn inside it - to a fixed colour. Left out by default: the strip keeps the colour Windows Terminal gives it, so it looks the same installed or not. Whatever you pass, it must be a fixed colour and never the project's: the strip is the base Windows Terminal composites background tabs over, so a stable base is what lets each background tab show its own colour. .PARAMETER ApplicationTheme Light or dark identity of the window chrome, which is what paints the tab row. Taken from the theme being replaced by default, so the strip does not flip when Windows is set to the other mode. .PARAMETER Force Reinstalls the theme even when it is already present. A theme left over from an earlier version is upgraded automatically, without this switch. .PARAMETER NoBackup Does not write a backup copy. .EXAMPLE Install-TerminalColorsTheme .EXAMPLE Install-TerminalColorsTheme -TabRowColor '#000000' Pitch-black tab row, instead of leaving it to Windows Terminal. .EXAMPLE Install-TerminalColorsTheme -WhatIf Shows what would change without writing anything. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] [OutputType([pscustomobject])] param( [string] $SettingsPath, [string] $TabRowColor, [ValidateSet('system', 'light', 'dark')] [string] $ApplicationTheme, [switch] $Force, [switch] $NoBackup ) $SettingsPath = Resolve-TcWtSettingsPath -Path $SettingsPath $text = [System.IO.File]::ReadAllText($SettingsPath) $settings = ConvertFrom-TcJsonText -Text $text if ($null -eq $settings) { throw "TerminalColors: [$SettingsPath] is not valid JSON. Fix it before installing the theme." } # --- Tab row: only pinned when asked for -------------------------------- $rowHex = $null if ($TabRowColor) { $rowRgb = ConvertFrom-TcColor -Value $TabRowColor if ($null -eq $rowRgb) { throw "TerminalColors: invalid tab-row colour [$TabRowColor]." } $rowHex = ConvertTo-TcHex -Rgb $rowRgb } # --- Chrome: inherited from the theme being replaced -------------------- # Selecting a theme replaces the previous one wholesale, so the light/dark # identity has to be carried over explicitly or the strip flips on a machine # whose Windows is in the other mode. On a reinstall the current value is our # own theme, so the one recorded at first install answers instead. if (-not $PSBoundParameters.ContainsKey('ApplicationTheme')) { $selected = Get-TcSelectedTheme -Text $text if ($selected -match '"TerminalColors"') { $selected = Get-TcInstallState -Name 'PreviousTheme' } $ApplicationTheme = Get-TcApplicationTheme -ThemeValue $selected } $alreadyInstalled = Test-TcThemeInstalled -Text $text # A theme from an earlier version makes background tabs borrow the active # project's colour: it is replaced without the caller having to ask. $outdated = ($alreadyInstalled -and -not (Test-TcThemeUpToDate -Settings $settings)) $replace = ($Force -or $outdated) if ($alreadyInstalled -and -not $replace) { Write-Verbose 'TerminalColors: theme already present and current, only the selection is checked.' } $newText = $text $masked = Get-TcMaskedJson -Text $newText $changes = @() # --- 1. Remove a previous version of the theme -------------------------- if ($alreadyInstalled -and $replace) { $m = [regex]::Match($masked, '"name"\s*:\s*"TerminalColors"') $span = Find-TcJsonObjectSpan -Text $masked -Index $m.Index if ($span) { $start = $span.Start $end = $span.End # Absorb the adjacent comma to keep the array valid $after = $end + 1 while ($after -lt $masked.Length -and [char]::IsWhiteSpace($masked[$after])) { $after++ } if ($after -lt $masked.Length -and $masked[$after] -eq ',') { $end = $after } else { $before = $start - 1 while ($before -ge 0 -and [char]::IsWhiteSpace($masked[$before])) { $before-- } if ($before -ge 0 -and $masked[$before] -eq ',') { $start = $before } } $newText = $newText.Remove($start, $end - $start + 1) $masked = Get-TcMaskedJson -Text $newText if ($outdated -and -not $Force) { $changes += 'theme from an earlier version upgraded' } else { $changes += 'previous theme removed' } $alreadyInstalled = $false } } # --- 2. Insert the theme into the "themes" array ------------------------ if (-not $alreadyInstalled) { $themeJson = Get-TcThemeJson -TabRowColor $rowHex -ApplicationTheme $ApplicationTheme $m = [regex]::Match($masked, '"themes"\s*:\s*\[') if ($m.Success) { $insertAt = $m.Index + $m.Length $probe = $insertAt while ($probe -lt $masked.Length -and [char]::IsWhiteSpace($masked[$probe])) { $probe++ } $needsComma = ($probe -lt $masked.Length -and $masked[$probe] -ne ']') $fragment = [Environment]::NewLine + ' ' + $themeJson if ($needsComma) { $fragment += ',' } else { $fragment += [Environment]::NewLine + ' ' } $newText = $newText.Insert($insertAt, $fragment) $changes += 'theme added to "themes"' } else { $rootBrace = $masked.IndexOf('{') if ($rootBrace -lt 0) { throw 'TerminalColors: unexpected settings.json structure.' } $fragment = [Environment]::NewLine + ' "themes":' + [Environment]::NewLine + ' [' + [Environment]::NewLine + ' ' + $themeJson + [Environment]::NewLine + ' ],' $newText = $newText.Insert($rootBrace + 1, $fragment) $changes += '"themes" section created' } $masked = Get-TcMaskedJson -Text $newText } # --- 3. Select the theme ------------------------------------------------ $previousTheme = $null $m = [regex]::Match($masked, '"theme"\s*:\s*(?:"[^"]*"|\{[^{}]*\})') if ($m.Success) { $currentValue = $newText.Substring($m.Index, $m.Length) if ($currentValue -notmatch '"TerminalColors"') { $vm = [regex]::Match($currentValue, '"theme"\s*:\s*(.+)$', 'Singleline') if ($vm.Success) { $previousTheme = $vm.Groups[1].Value.Trim() } $newText = $newText.Remove($m.Index, $m.Length).Insert($m.Index, '"theme": "TerminalColors"') $changes += 'theme selected' } } else { $rootBrace = $masked.IndexOf('{') $newText = $newText.Insert($rootBrace + 1, [Environment]::NewLine + ' "theme": "TerminalColors",') $changes += 'theme selected' } if ($changes.Count -eq 0) { Write-Verbose 'TerminalColors: nothing to change.' return [pscustomobject]@{ SettingsPath = $SettingsPath Changed = $false Changes = @() TabRowColor = $rowHex ApplicationTheme = $ApplicationTheme Backup = $null } } # --- 4. Validate before writing ----------------------------------------- $parsed = ConvertFrom-TcJsonText -Text $newText if ($null -eq $parsed) { throw 'TerminalColors: the change would have produced invalid JSON. Nothing was written. Please report this case along with your settings.json.' } if ([string]$parsed.theme -ne $script:TcThemeName) { throw 'TerminalColors: verification failed (theme not selected). Nothing was written.' } if (-not ($parsed.themes | Where-Object { $_.name -eq $script:TcThemeName })) { throw 'TerminalColors: verification failed (theme missing from the list). Nothing was written.' } if (-not (Test-TcThemeUpToDate -Settings $parsed)) { throw 'TerminalColors: verification failed (tabs would not each carry their own colour, or the tab row would follow the active project). Nothing was written.' } $backupPath = $null if ($PSCmdlet.ShouldProcess($SettingsPath, "Install the TerminalColors theme ($($changes -join ', '))")) { $backupPath = Save-TcWtSettings -Path $SettingsPath -Text $newText -NoBackup:$NoBackup if ($previousTheme) { Set-TcInstallState -Name 'PreviousTheme' -Value $previousTheme } } return [pscustomobject]@{ SettingsPath = $SettingsPath Changed = $true Changes = $changes TabRowColor = $rowHex ApplicationTheme = $ApplicationTheme Backup = $backupPath } } function Uninstall-TerminalColorsTheme { <# .SYNOPSIS Removes the [TerminalColors] theme from Windows Terminal and restores the previously selected theme. .PARAMETER SettingsPath Path of the settings.json to modify. Detected automatically by default. .PARAMETER NoBackup Does not write a backup copy. .EXAMPLE Uninstall-TerminalColorsTheme #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')] param( [string] $SettingsPath, [switch] $NoBackup ) $SettingsPath = Resolve-TcWtSettingsPath -Path $SettingsPath $text = [System.IO.File]::ReadAllText($SettingsPath) $newText = $text $masked = Get-TcMaskedJson -Text $newText $changes = @() $m = [regex]::Match($masked, '"name"\s*:\s*"TerminalColors"') if ($m.Success) { $span = Find-TcJsonObjectSpan -Text $masked -Index $m.Index if ($span) { $start = $span.Start $end = $span.End $after = $end + 1 while ($after -lt $masked.Length -and [char]::IsWhiteSpace($masked[$after])) { $after++ } if ($after -lt $masked.Length -and $masked[$after] -eq ',') { $end = $after } else { $before = $start - 1 while ($before -ge 0 -and [char]::IsWhiteSpace($masked[$before])) { $before-- } if ($before -ge 0 -and $masked[$before] -eq ',') { $start = $before } } $newText = $newText.Remove($start, $end - $start + 1) $masked = Get-TcMaskedJson -Text $newText $changes += 'theme removed' } } $restore = Get-TcInstallState -Name 'PreviousTheme' if (-not $restore) { $restore = '"system"' } $m = [regex]::Match($masked, '"theme"\s*:\s*(?:"[^"]*"|\{[^{}]*\})') if ($m.Success -and $newText.Substring($m.Index, $m.Length) -match '"TerminalColors"') { $newText = $newText.Remove($m.Index, $m.Length).Insert($m.Index, '"theme": ' + $restore) $changes += "theme restored ($restore)" } if ($changes.Count -eq 0) { Write-Warning 'TerminalColors: the theme was not installed.' return } if ($null -eq (ConvertFrom-TcJsonText -Text $newText)) { throw 'TerminalColors: the change would have produced invalid JSON. Nothing was written.' } if ($PSCmdlet.ShouldProcess($SettingsPath, "Remove the TerminalColors theme ($($changes -join ', '))")) { [void](Save-TcWtSettings -Path $SettingsPath -Text $newText -NoBackup:$NoBackup) } } |