Public/Install-TerminalColorsBackdrop.ps1

# Opaque backdrop: what makes it possible to have the tab, the title bar and the
# border in PURE colour while leaving the pane background unchanged.
#
# Why it is needed. A Windows Terminal theme only accepts four values for
# tab.background and tabRow.background: terminalBackground, accent, a fixed
# colour, or nothing. The only one steerable at runtime is terminalBackground.
# In other words [tab colour] and [background colour] are one and the same
# channel - hence the historical 30 % dilution.
#
# The backdrop decouples the two: OSC 11 sends the PURE project colour (which
# Windows Terminal copies onto the tab and the title bar), and an opaque
# background image of your usual background colour is laid over the pane. Windows
# Terminal paints the tab from the background colour, never from the image: the
# tab becomes vivid, the pane stays as it was.
#
# Verified: background colour #FF0000 plus a #0C0C0C image does give a tab and a
# title bar at #FF0000 for a pane measured at #0C0C0C.

$script:TcBackdropPrefix = 'backdrop-'

function Get-TcBackdropDirectory {
    return (Get-TcDataDirectory)
}

function Get-TcBackdropPath {
    param([hashtable] $Rgb)

    $name = '{0}{1:X2}{2:X2}{3:X2}.png' -f $script:TcBackdropPrefix, [int]$Rgb.R, [int]$Rgb.G, [int]$Rgb.B
    return (Join-Path (Get-TcBackdropDirectory) $name)
}

function Test-TcBackdropPath {
    <#
        .SYNOPSIS
        Indicates whether a background image path is an image generated by
        TerminalColors (rather than one of the user's own).
    #>

    param([string] $Path)

    if ([string]::IsNullOrWhiteSpace($Path)) { return $false }
    $leaf = ''
    try { $leaf = Split-Path $Path -Leaf } catch { return $false }
    return ($leaf -like ($script:TcBackdropPrefix + '*.png'))
}

function ConvertTo-TcJsonString {
    <#
        .SYNOPSIS
        JSON string literal, quotes included, escaping applied.
    #>

    param([string] $Value)

    $escaped = $Value.Replace('\', '\\').Replace('"', '\"')
    return '"' + $escaped + '"'
}

function Initialize-TcWtProfileDefaults {
    <#
        .SYNOPSIS
        Guarantees that profiles.defaults exists in the settings.json text and
        returns @{ Text; Anchor } where Anchor points at that object's opening
        brace.
    #>

    param([string] $Text, [int] $Depth = 0)

    if ($Depth -gt 3) { throw 'TerminalColors: unable to create profiles.defaults in settings.json.' }

    $masked = Get-TcMaskedJson -Text $Text
    $m = [regex]::Match($masked, '"profiles"\s*:\s*')
    if (-not $m.Success) {
        $root = Find-TcJsonRootIndex -Masked $masked
        $fragment = [Environment]::NewLine + ' "profiles":' + [Environment]::NewLine + ' {' +
                    [Environment]::NewLine + ' "defaults": {}' + [Environment]::NewLine + ' },'
        return (Initialize-TcWtProfileDefaults -Text $Text.Insert($root + 1, $fragment) -Depth ($Depth + 1))
    }

    $valueStart = $m.Index + $m.Length
    if ($valueStart -ge $masked.Length) { throw 'TerminalColors: "profiles" is truncated in settings.json.' }
    if ($masked[$valueStart] -eq '[') {
        throw 'TerminalColors: this settings.json uses the legacy format where "profiles" is an array. Open the Windows Terminal settings and save them once to convert it, then try again.'
    }
    if ($masked[$valueStart] -ne '{') { throw 'TerminalColors: "profiles" is not an object in settings.json.' }

    $profilesSpan = Find-TcJsonBlockSpan -Text $masked -Index $valueStart
    if ($null -eq $profilesSpan) { throw 'TerminalColors: "profiles" is malformed in settings.json.' }

    $defaults = Find-TcJsonMember -Masked $masked -Span $profilesSpan -Name 'defaults'
    if ($null -eq $defaults) {
        $fragment = [Environment]::NewLine + ' "defaults": {},'
        return (Initialize-TcWtProfileDefaults -Text $Text.Insert($profilesSpan.Start + 1, $fragment) -Depth ($Depth + 1))
    }
    if ($masked[$defaults.ValueStart] -ne '{') { throw 'TerminalColors: profiles.defaults is not an object in settings.json.' }

    return @{ Text = $Text; Anchor = $defaults.ValueStart }
}

function Get-TcBackdropSettings {
    <#
        .SYNOPSIS
        Reads the backdrop state from a parsed settings.json.
    #>

    param($Settings)

    $result = @{ Image = $null; Opacity = $null; StretchMode = $null; ProfileOverrides = @() }

    $profiles = Get-TcJsonProperty -InputObject $Settings -Name 'profiles'
    if ($null -eq $profiles) { return $result }

    $defaults = Get-TcJsonProperty -InputObject $profiles -Name 'defaults'
    if ($defaults) {
        $result.Image = Get-TcJsonProperty -InputObject $defaults -Name 'backgroundImage'
        $result.Opacity = Get-TcJsonProperty -InputObject $defaults -Name 'backgroundImageOpacity'
        $result.StretchMode = Get-TcJsonProperty -InputObject $defaults -Name 'backgroundImageStretchMode'
    }

    # An image set on a specific profile overrides profiles.defaults: the backdrop
    # would then have no effect in that profile.
    $list = Get-TcJsonProperty -InputObject $profiles -Name 'list'
    if ($list) {
        $overrides = @()
        foreach ($p in $list) {
            $image = Get-TcJsonProperty -InputObject $p -Name 'backgroundImage'
            if ($image -and -not (Test-TcBackdropPath -Path ([string]$image))) {
                $label = [string](Get-TcJsonProperty -InputObject $p -Name 'name')
                if (-not $label) { $label = [string](Get-TcJsonProperty -InputObject $p -Name 'guid') }
                $overrides += $label
            }
        }
        $result.ProfileOverrides = $overrides
    }

    return $result
}

function Install-TerminalColorsBackdrop {
    <#
        .SYNOPSIS
        Installs the opaque backdrop: the tab, the title bar and the border switch
        to pure colour, and the pane background stops changing.
 
        .DESCRIPTION
        Generates a solid PNG image of your current background colour and declares
        it as the background image in Windows Terminal's profiles.defaults
        (backgroundImage, backgroundImageOpacity, backgroundImageStretchMode).
 
        Windows Terminal paints the tab and the title bar from the background
        colour, never from the background image. The background colour can
        therefore carry the pure project colour without the pane changing at all.
 
        Combine this with [Enable-TerminalColors -PureColor], which sends the
        project colour undiluted. The TerminalColors theme is still required: it is
        what ties the tab to the background colour.
 
        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.
 
        .PARAMETER Color
        Backdrop colour, that is the colour the pane will keep. Defaults to the
        background colour already declared by your profile or your Windows Terminal
        colour scheme, so the pane stays exactly as it is.
 
        .PARAMETER Force
        Replaces a background image already declared that does not come from
        TerminalColors. Without this switch the command stops rather than
        overwriting your setting.
 
        .PARAMETER NoBackup
        Does not write a backup copy.
 
        .EXAMPLE
        Install-TerminalColorsBackdrop
 
        .EXAMPLE
        Install-TerminalColorsBackdrop -Color '#000000'
        Pins a perfectly black pane, whatever the profile's colour scheme.
 
        .EXAMPLE
        Install-TerminalColorsBackdrop -WhatIf
        Shows what would change without writing anything.
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([pscustomobject])]
    param(
        [string] $SettingsPath,
        [string] $Color,
        [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 backdrop."
    }

    # --- 1. Backdrop colour --------------------------------------------------
    if ($Color) {
        $rgb = ConvertFrom-TcColor -Value $Color
        if ($null -eq $rgb) { throw "TerminalColors: invalid backdrop colour [$Color]." }
    } else {
        $rgb = Get-TcSettingsBackground -Settings $settings -ProfileId $env:WT_PROFILE_ID
        if ($null -eq $rgb) { $rgb = ConvertFrom-TcColor -Value $script:TcDefaultBaseBackground }
    }
    $hex = ConvertTo-TcHex -Rgb $rgb
    $imagePath = Get-TcBackdropPath -Rgb $rgb

    # --- 2. Refuse to overwrite an image of the user's own -------------------
    $current = Get-TcBackdropSettings -Settings $settings
    $previousImage = [string]$current.Image
    $foreign = ($previousImage -and -not (Test-TcBackdropPath -Path $previousImage))
    if ($foreign -and -not $Force) {
        throw "TerminalColors: a background image is already declared in profiles.defaults [$previousImage]. Use -Force to replace it (it will be restored by Uninstall-TerminalColorsBackdrop)."
    }

    # --- 3. Edit settings.json ------------------------------------------------
    $prepared = Initialize-TcWtProfileDefaults -Text $text
    $newText = $prepared.Text
    $anchor = $prepared.Anchor
    $changes = @()

    $members = @(
        @{ Name = 'backgroundImage'; Literal = (ConvertTo-TcJsonString -Value $imagePath) }
        @{ Name = 'backgroundImageOpacity'; Literal = '1.0' }
        @{ Name = 'backgroundImageStretchMode'; Literal = '"fill"' }
    )
    foreach ($member in $members) {
        # Every edit invalidates the indexes: relocate the anchor each time.
        $prepared = Initialize-TcWtProfileDefaults -Text $newText
        $newText = $prepared.Text
        $anchor = $prepared.Anchor

        $applied = Set-TcJsonMember -Text $newText -AnchorIndex $anchor -Name $member.Name -Literal $member.Literal
        $newText = $applied.Text
        if ($applied.Action -ne 'unchanged') { $changes += "$($member.Name) $($applied.Action)" }
    }

    # --- 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.'
    }
    $check = Get-TcBackdropSettings -Settings $parsed
    if ([string]$check.Image -ne $imagePath) {
        throw 'TerminalColors: verification failed (background image not declared). Nothing was written.'
    }
    if ([double]$check.Opacity -ne 1.0) {
        throw 'TerminalColors: verification failed (opacity other than 1). Nothing was written.'
    }
    if ([string]$check.StretchMode -ne 'fill') {
        throw 'TerminalColors: verification failed (unexpected stretch mode). Nothing was written.'
    }

    # --- 5. Write -------------------------------------------------------------
    $backupPath = $null
    $written = $false
    $target = "Install the opaque backdrop $hex"
    if ($changes.Count -gt 0) { $target = "$target ($($changes -join ', '))" }

    if ($PSCmdlet.ShouldProcess($SettingsPath, $target)) {
        Write-TcSolidPng -Path $imagePath -Rgb $rgb | Out-Null

        # Backdrops for older colours are of no further use.
        Get-ChildItem -Path (Get-TcBackdropDirectory) -Filter ($script:TcBackdropPrefix + '*.png') -ErrorAction SilentlyContinue |
            Where-Object { $_.FullName -ne $imagePath } |
            Remove-Item -Force -ErrorAction SilentlyContinue

        if ($changes.Count -gt 0) {
            if ($foreign) {
                Set-TcInstallState -Name 'PreviousBackgroundImage' -Value $previousImage
                Set-TcInstallState -Name 'PreviousBackgroundImageOpacity' -Value $current.Opacity
                Set-TcInstallState -Name 'PreviousBackgroundImageStretchMode' -Value $current.StretchMode
            }
            $backupPath = Save-TcWtSettings -Path $SettingsPath -Text $newText -NoBackup:$NoBackup
        }
        $written = $true
    }

    return [pscustomobject]@{
        SettingsPath = $SettingsPath
        Changed      = ($changes.Count -gt 0)
        Changes      = $changes
        Color        = $hex
        ImagePath    = $imagePath
        ImageWritten = $written
        Replaced     = $foreign
        Backup       = $backupPath
    }
}

function Uninstall-TerminalColorsBackdrop {
    <#
        .SYNOPSIS
        Removes the opaque backdrop and restores the previous background image if
        there was one.
 
        .DESCRIPTION
        After this command the pane background follows the colour sent by the
        module again: remember to drop [-PureColor] from your profile, otherwise
        the pane will take the pure project colour.
 
        .PARAMETER SettingsPath
        Path of the settings.json to modify. Detected automatically by default.
 
        .PARAMETER NoBackup
        Does not write a backup copy.
 
        .EXAMPLE
        Uninstall-TerminalColorsBackdrop
    #>

    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([pscustomobject])]
    param(
        [string] $SettingsPath,
        [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."
    }

    $current = Get-TcBackdropSettings -Settings $settings
    if (-not $current.Image) {
        Write-Warning 'TerminalColors: no opaque backdrop declared in profiles.defaults.'
        return
    }
    if (-not (Test-TcBackdropPath -Path ([string]$current.Image))) {
        Write-Warning "TerminalColors: the declared background image [$($current.Image)] does not come from TerminalColors; it is left in place."
        return
    }

    $restoreImage = Get-TcInstallState -Name 'PreviousBackgroundImage'
    $newText = $text
    $changes = @()

    if ($restoreImage) {
        $values = @(
            @{ Name = 'backgroundImage'; Literal = (ConvertTo-TcJsonString -Value ([string]$restoreImage)) }
            @{ Name = 'backgroundImageOpacity'; State = 'PreviousBackgroundImageOpacity' }
            @{ Name = 'backgroundImageStretchMode'; State = 'PreviousBackgroundImageStretchMode' }
        )
        foreach ($value in $values) {
            $literal = $value.Literal
            if (-not $literal) {
                $saved = Get-TcInstallState -Name $value.State
                if ($null -eq $saved) { continue }
                if ($saved -is [string]) { $literal = ConvertTo-TcJsonString -Value $saved } else { $literal = [string]$saved }
            }
            $prepared = Initialize-TcWtProfileDefaults -Text $newText
            $applied = Set-TcJsonMember -Text $prepared.Text -AnchorIndex $prepared.Anchor -Name $value.Name -Literal $literal
            $newText = $applied.Text
            if ($applied.Action -ne 'unchanged') { $changes += "$($value.Name) restored" }
        }
    } else {
        foreach ($name in @('backgroundImage', 'backgroundImageOpacity', 'backgroundImageStretchMode')) {
            $prepared = Initialize-TcWtProfileDefaults -Text $newText
            $applied = Remove-TcJsonMember -Text $prepared.Text -AnchorIndex $prepared.Anchor -Name $name
            $newText = $applied.Text
            if ($applied.Action -eq 'removed') { $changes += "$name removed" }
        }
    }

    if ($changes.Count -eq 0) {
        Write-Warning 'TerminalColors: nothing to remove.'
        return
    }

    if ($null -eq (ConvertFrom-TcJsonText -Text $newText)) {
        throw 'TerminalColors: the change would have produced invalid JSON. Nothing was written.'
    }

    $backupPath = $null
    if ($PSCmdlet.ShouldProcess($SettingsPath, "Remove the opaque backdrop ($($changes -join ', '))")) {
        $backupPath = Save-TcWtSettings -Path $SettingsPath -Text $newText -NoBackup:$NoBackup
        Get-ChildItem -Path (Get-TcBackdropDirectory) -Filter ($script:TcBackdropPrefix + '*.png') -ErrorAction SilentlyContinue |
            Remove-Item -Force -ErrorAction SilentlyContinue
        foreach ($name in @('PreviousBackgroundImage', 'PreviousBackgroundImageOpacity', 'PreviousBackgroundImageStretchMode')) {
            Remove-TcInstallState -Name $name
        }
    }

    return [pscustomobject]@{
        SettingsPath = $SettingsPath
        Changed      = $true
        Changes      = $changes
        Backup       = $backupPath
    }
}

function Test-TerminalColorsBackdrop {
    <#
        .SYNOPSIS
        Opaque backdrop state: is it declared, does the image exist, and which
        colour is the pane pinned to.
 
        .PARAMETER SettingsPath
        Path of the settings.json to inspect. Detected automatically by default.
 
        .EXAMPLE
        Test-TerminalColorsBackdrop
    #>

    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([string] $SettingsPath)

    if (-not $SettingsPath) { $SettingsPath = Get-TcWtSettingsPath }

    $result = [pscustomobject]@{
        SettingsPath     = $SettingsPath
        Installed        = $false
        ImagePath        = $null
        ImageExists      = $false
        Color            = $null
        Opacity          = $null
        StretchMode      = $null
        ForeignImage     = $false
        ProfileOverrides = @()
    }

    if (-not $SettingsPath -or -not [System.IO.File]::Exists($SettingsPath)) { return $result }

    $settings = ConvertFrom-TcJsonFile -Path $SettingsPath
    if ($null -eq $settings) { return $result }

    $current = Get-TcBackdropSettings -Settings $settings
    $image = [string]$current.Image
    $result.ImagePath = $current.Image
    $result.Opacity = $current.Opacity
    $result.StretchMode = $current.StretchMode
    $result.ProfileOverrides = $current.ProfileOverrides

    if (-not $image) { return $result }

    if (-not (Test-TcBackdropPath -Path $image)) {
        $result.ForeignImage = $true
        return $result
    }

    $result.Installed = $true
    $result.ImageExists = [System.IO.File]::Exists($image)

    $leaf = Split-Path $image -Leaf
    $m = [regex]::Match($leaf, '^' + [regex]::Escape($script:TcBackdropPrefix) + '([0-9A-Fa-f]{6})\.png$')
    if ($m.Success) { $result.Color = '#' + $m.Groups[1].Value.ToUpperInvariant() }

    return $result
}