lib/applystyle.ps1

# applystyle.ps1 -- applying and resetting a style, and the read-only subcommands.
#
# Dot-sourced by tstyles.ps1, so everything here shares its $script: scope.
#
# One fork runs through all of it: is this Windows Terminal? On WT a style is a
# merge into settings.json and WT repaints from its own file watch. Everywhere
# else it is an OSC packet pushed at the live tab, a recorded style name so a new
# tab can re-emit it, and the staged zsh/bash runtime. Reset is the inverse and
# has to undo whichever half ran.

function Show-StyleList {
    # `tstyles list` -- print available styles, marking the active one.
    Show-UpdateNoticeIfAvailable
    $current = Get-CurrentStyleName
    $styles = Get-AvailableStyles
    Write-Host ""
    Write-Host "Available styles:" -ForegroundColor Cyan
    foreach ($s in $styles) {
        $marker = if ($s.Name -eq $current) { '*' } else { ' ' }
        # A user-authored style with a malformed or unreadable scheme.json used
        # to throw here, mid-loop -- so `tstyles list` printed a raw .NET
        # exception and then stopped, hiding every style after it. One bad
        # folder should cost its own row, not the listing.
        $schemePath = Join-Path $s.FullName 'scheme.json'
        $swatch = ''
        try {
            $scheme = [System.IO.File]::ReadAllText($schemePath, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json
            $swatch = Get-SchemeSwatch -Scheme $scheme
        } catch {
            $swatch = "$([char]27)[38;2;160;160;160m(unreadable scheme.json)$([char]27)[0m"
        }
        Write-Host (" {0} {1,-16} {2}" -f $marker, $s.Name, $swatch)
    }
    Write-Host ""
    if ($current) {
        Write-Host "$([char]27)[38;2;160;160;160m (* = currently active)$([char]27)[0m"
    } else {
        Write-Host "$([char]27)[38;2;160;160;160m (no bundled style currently active)$([char]27)[0m"
    }
    Write-Host ""
}

function Show-CurrentStyle {
    # `tstyles current` -- print the active style name. Interactive callers
    # see name + swatch (visual self-check); piped/redirected callers get
    # just the name on stdout, preserving scriptability for `tstyles current
    # | grep ...` etc.
    Show-UpdateNoticeIfAvailable
    $current = Get-CurrentStyleName
    if ($current) {
        if ([Console]::IsOutputRedirected) {
            Write-Output $current
        } else {
            $styleDir = Get-StyleDir -StyleName $current
            $schemePath = if ($styleDir) { Join-Path $styleDir 'scheme.json' } else { $null }
            if ($schemePath -and (Test-Path -LiteralPath $schemePath)) {
                $scheme = [System.IO.File]::ReadAllText($schemePath, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json
                Write-Host ("{0,-16} {1}" -f $current, (Get-SchemeSwatch -Scheme $scheme))
            } else {
                Write-Host $current
            }
        }
    } else {
        Write-Host "$([char]27)[38;2;160;160;160m(no bundled style currently active)$([char]27)[0m"
    }
}

function Invoke-RandomStyle {
    # `tstyles random` -- pick a random bundled style and apply it.
    # Excludes the currently active one so it actually changes.
    #
    # Takes the same apply flags as `tstyles <name>` and forwards them. It used
    # to call Apply-StyleDirect with only the style name, so `tstyles random
    # -KeepPrompt` replaced the prompt it promised to keep, `-Target` applied to
    # the wrong Windows Terminal profile, and `-NewWindow` did nothing.
    param(
        [string]$Target,
        [switch]$KeepPrompt,
        [switch]$NewWindow
    )
    Show-UpdateNoticeIfAvailable
    $current = Get-CurrentStyleName
    $candidates = @(Get-AvailableStyles | Where-Object { $_.Name -ne $current })
    if (-not $candidates) {
        Write-Host "No other styles to switch to." -ForegroundColor Yellow
        return
    }
    $pick = $candidates | Get-Random
    Write-Host ""
    Write-Host "Rolling the dice... -> " -NoNewline
    Write-Host $pick.Name -ForegroundColor Cyan
    Apply-StyleDirect -StyleName $pick.Name -Target $Target `
        -KeepPrompt:$KeepPrompt -NewWindow:$NewWindow
}

function Apply-StyleNonWT {
    # Apply a style on a terminal that is not Windows Terminal.
    #
    # There is no settings.json to merge into, so the work splits in two:
    # * colors -- emitted as an OSC packet, which retints the CURRENT tab
    # instantly. Recorded in current-style.json so the startup
    # block at the bottom of this file can re-emit it into
    # every future tab.
    # * prompt -- the style's profile.ps1 is copied to current-style.ps1
    # and dot-sourced, exactly as on Windows Terminal. That
    # file is plain PowerShell + ANSI and is already portable.
    #
    # Fields the host terminal cannot honour (a background image on Terminal.app,
    # a tab accent color anywhere but WT) are reported rather than silently
    # dropped, so the user knows why the style looks plainer than its screenshot.
    param(
        [Parameter(Mandatory)][string]$StyleName,
        [Parameter(Mandatory)][string]$StyleDir,
        [switch]$KeepPrompt,
        # Open a new window carrying the FULL style, background image included.
        # Needed because an image can only reach Terminal.app through a profile,
        # and a profile only takes effect on a new window -- unlike colors,
        # which the OSC packet applies to the window you are already in.
        [switch]$NewWindow
    )

    $kind = Get-TerminalKind
    $caps = Get-TerminalCapability -Kind $kind

    $schemePath = Join-Path $StyleDir 'scheme.json'
    if (-not (Test-Path -LiteralPath $schemePath)) {
        Write-Error "Style '$StyleName' has no scheme.json."
        return
    }
    $scheme = [System.IO.File]::ReadAllText($schemePath, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json

    $applied = Invoke-TerminalStyleOscApply -Scheme $scheme -Kind $kind
    Set-CurrentStyleRecord -StyleName $StyleName -Kind $kind

    # Stage the zsh/bash side too. The user's login shell is probably not
    # PowerShell, and the colors belong to the terminal rather than to any one
    # shell -- so a zsh tab opened after this should come up styled as well.
    Set-ShellStyleState -StyleName $StyleName -StyleDir $StyleDir -Scheme $scheme -KeepPrompt:$KeepPrompt

    # Prompt/banner: same contract as the Windows Terminal path.
    $styleProfile = Join-Path $StyleDir 'profile.ps1'
    if (-not $KeepPrompt -and (Test-Path -LiteralPath $styleProfile)) {
        Copy-Item -LiteralPath $styleProfile -Destination $script:TStylesCurrent -Force
    } elseif (Test-Path -LiteralPath $script:TStylesCurrent) {
        Remove-Item -LiteralPath $script:TStylesCurrent -Force
    }

    Write-Host ""
    Write-Host " Style applied: " -NoNewline
    Write-Host $StyleName -ForegroundColor Green
    Write-Host " Terminal: " -NoNewline
    Write-Host (Get-TerminalDisplayName -Kind $kind) -ForegroundColor Cyan

    if (-not $applied) {
        Write-Host ""
        if ([Console]::IsOutputRedirected) {
            # The style IS recorded and staged -- a new tab will come up in it.
            # What could not happen is repainting THIS session, because its
            # output does not go to a terminal. Say that precisely: the
            # alternative is a user watching an unchanged window after being
            # told the style was applied.
            Write-Host " Colors were not applied to this session: its output is redirected," -ForegroundColor Yellow
            Write-Host " so there is no terminal to repaint. The style is saved -- open a new" -ForegroundColor Yellow
            Write-Host " tab, or run tstyles directly in your terminal, to see it." -ForegroundColor Yellow
        } else {
            Write-Host " Note: this terminal did not accept live color changes, so only the prompt was applied." -ForegroundColor Yellow
        }
    }

    # Tell the user which parts of the style this terminal cannot show, once,
    # rather than letting them wonder why it doesn't match the screenshot.
    $unsupported = @()
    $theme = $null
    $themePath = Join-Path $StyleDir 'theme.json'
    if (Test-Path -LiteralPath $themePath) {
        try {
            $theme = [System.IO.File]::ReadAllText($themePath, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json
        } catch { $theme = $null }
    }
    if ($theme) {
        # Capability first, deliberately. Get-StyleBundledBackground can make up
        # to four serial 10-second HTTP attempts against the gifs branch, so as
        # the LEFT operand it ran even where the answer could not matter -- and
        # ran a second time below. Ordered this way the two calls below and here
        # are mutually exclusive on $caps.BackgroundImage, so an apply resolves
        # the background at most once.
        if (-not $caps.BackgroundImage -and (Get-StyleBundledBackground -StyleDir $StyleDir)) {
            $unsupported += 'background image'
        }
        if ($theme.PSObject.Properties.Match('tabColor').Count -gt 0 -and -not $caps.TabColor) {
            $unsupported += 'tab color'
        }
    }
    if ($unsupported.Count -gt 0) {
        Write-Host ""
        Write-Host (" {0} can't show: {1}." -f (Get-TerminalDisplayName -Kind $kind), ($unsupported -join ', ')) -ForegroundColor DarkGray
    }

    # Background image. It cannot be pushed into this window -- only a profile
    # carries one, and a profile only applies to a new window -- so the profile
    # is written either way and opened only when asked. Silently spawning a
    # window on every apply would be a worse surprise than not showing the image.
    $bundledBg = if ($caps.BackgroundImage -and $kind -eq 'AppleTerminal') {
        Get-StyleBundledBackground -StyleDir $StyleDir
    } else { $null }
    if ($bundledBg) {
        $profilePath = New-AppleTerminalProfile -StyleName $StyleName -Scheme $scheme -BackgroundImage $bundledBg
        if ($profilePath) {
            if ($NewWindow) {
                Write-Host ""
                Write-Host " Opening a new window with the background image..." -ForegroundColor DarkGray
                try { & open $profilePath } catch {
                    Write-Host " Could not open the profile: $_" -ForegroundColor Yellow
                }
            } else {
                Write-Host ""
                Write-Host " This style ships a background image, which Terminal.app can only show" -ForegroundColor DarkGray
                Write-Host " in a new window. To get it:" -ForegroundColor DarkGray
                Write-Host " tstyles $StyleName -NewWindow" -ForegroundColor Cyan
            }
        }
    }
    Write-Host ""

    # Live reload of the prompt in THIS shell (matches the WT confirm path).
    if (Test-Path -LiteralPath $script:TStylesCurrent) {
        . $script:TStylesCurrent
    }
}

function Apply-StyleDirect {
    # Apply a style directly (no picker UI). Used by `tstyles <name>` and
    # `tstyles random`. Mirrors the picker's confirm path -- merge into
    # settings.json, copy profile.ps1 to current-style.ps1, dot-source for
    # live reload.
    #
    # Off Windows Terminal the settings.json half does not exist; Apply-StyleNonWT
    # takes over with the OSC + current-style.json path instead.
    param(
        [Parameter(Mandatory)][string]$StyleName,
        [string]$Target,
        [string]$BackgroundImage,
        [bool]$BackgroundImageProvided = $false,
        # Apply the visuals but not the style's prompt/banner: clears
        # current-style.ps1 so the user's own prompt stays in control.
        [switch]$KeepPrompt,
        # Off Windows Terminal: also open a new window carrying the style's
        # background image (see Apply-StyleNonWT).
        [switch]$NewWindow
    )

    $styleDir = Get-StyleDir -StyleName $StyleName
    if (-not $styleDir) {
        Write-Error "Style '$StyleName' not found. Run 'tstyles list' to see available styles."
        return
    }

    Show-UpdateNoticeIfAvailable

    # Non-WT hosts have no settings.json; hand off before we go looking for one.
    if ((Get-TerminalKind) -ne 'WindowsTerminal') {
        Apply-StyleNonWT -StyleName $StyleName -StyleDir $styleDir -KeepPrompt:$KeepPrompt -NewWindow:$NewWindow
        return
    }

    $settingsPath = Find-WTSettingsPath
    if (-not $settingsPath) {
        Write-Error "Could not locate Windows Terminal settings.json."
        return
    }

    $originalJson = [System.IO.File]::ReadAllText($settingsPath, [System.Text.UTF8Encoding]::new($false))
    $settings = ConvertFrom-WTJson $originalJson

    if (-not $Target) { $Target = Get-CurrentWTProfileName -Settings $settings }
    if (-not $Target) {
        Write-Error "Could not auto-detect a Windows Terminal profile. Pass -Target <name>."
        return
    }

    # Validate the target BEFORE anything is written. Merge-StyleIntoSettings
    # returns the settings untouched when the named profile does not exist, but
    # this function used to write and report success regardless -- so a typo in
    # -Target printed "Style applied" in green having applied nothing. Worse,
    # the write was not a no-op: Write-SettingsFile re-serializes the PARSED
    # object, and ConvertFrom-WTJson has already stripped every comment the user
    # wrote in their settings.json. A misspelled profile name silently and
    # irreversibly deleted their JSONC comments.
    if ($Target -ne 'defaults') {
        $targetEntry = $settings.profiles.list | Where-Object name -eq $Target | Select-Object -First 1
        if (-not $targetEntry) {
            $available = @('defaults') + @($settings.profiles.list.name | Where-Object { $_ })
            Write-Error ("Windows Terminal profile '$Target' not found. Available: " +
                         ($available -join ', '))
            return
        }
    }

    # Rolling backup: copy the on-disk settings.json to settings.json.bak
    # before any mutation. Single file, overwritten on each direct apply --
    # gives the user a one-line undo without filling LocalState with timestamped
    # backups over time. The picker doesn't need this (Esc reverts in-memory);
    # apply.ps1 keeps its own timestamped audit trail. -ErrorAction Stop so
    # non-terminating errors (permission denied, etc.) enter the catch block
    # rather than silently logging via $Error.
    $bakPath = "$settingsPath.bak"
    try {
        Copy-Item -LiteralPath $settingsPath -Destination $bakPath -Force -ErrorAction Stop
        Write-Host "Backed up settings to: $bakPath" -ForegroundColor Gray
    } catch {
        Write-Host "Warning: could not write backup ($_); proceeding anyway." -ForegroundColor Yellow
    }

    $settings = Merge-StyleIntoSettings -Settings $settings -StyleDir $styleDir `
        -TargetName $Target -BackgroundImage $BackgroundImage `
        -BackgroundImageProvided $BackgroundImageProvided
    Write-SettingsFile -Path $settingsPath -Settings $settings

    # Detect pwsh target for profile.ps1 install + live reload
    $isPwshTarget = $false
    if ($Target -eq 'defaults') {
        $isPwshTarget = $true
    } else {
        $entry = $settings.profiles.list | Where-Object name -eq $Target | Select-Object -First 1
        $cmd = "$($entry.commandline)"
        $src = "$($entry.source)"
        if ($src -eq 'Windows.Terminal.PowershellCore' -or
            $cmd -match '(?i)\bpwsh\.exe\b' -or
            $cmd -match '(?i)\bpowershell\.exe\b') {
            $isPwshTarget = $true
        }
    }

    $styleProfile = Join-Path $styleDir 'profile.ps1'
    if ($isPwshTarget) {
        if (-not $KeepPrompt -and (Test-Path -LiteralPath $styleProfile)) {
            Copy-Item -LiteralPath $styleProfile -Destination $script:TStylesCurrent -Force
        } elseif (Test-Path -LiteralPath $script:TStylesCurrent) {
            Remove-Item -LiteralPath $script:TStylesCurrent -Force
        }
    }

    Write-Host ""
    Write-Host " Style applied: " -NoNewline
    Write-Host $StyleName -ForegroundColor Green
    Write-Host ""

    # Live reload (same pattern as the picker's confirm path)
    if ($isPwshTarget -and (Test-InWindowsTerminal) -and (Test-Path -LiteralPath $script:TStylesCurrent)) {
        . $script:TStylesCurrent
    }
}

function Test-InWindowsTerminal {
    # True when the current session is hosted by Windows Terminal (which sets
    # WT_SESSION). WT is the only host that renders a style's colors/background,
    # so the themed prompt/banner is loaded only here.
    return [bool]$env:WT_SESSION
}

function Reset-StyleNonWT {
    # `tstyles reset` off Windows Terminal.
    #
    # Nothing was written to a settings file, so there is nothing to strip --
    # the applied colors live entirely in the terminal's dynamic-color state.
    # OSC 104/110/111/112/117 hands that state back to the terminal's own
    # configured profile, which is exactly what "unstyled default" means here.
    $kind = Get-TerminalKind

    [void](Invoke-TerminalStyleOscReset -Kind $kind)
    Clear-CurrentStyleRecord
    Clear-ShellStyleState

    # Restore the user's own prompt by removing the style's loader target. The
    # prompt function already installed in THIS session stays until the shell
    # restarts -- same behaviour as the Windows Terminal path.
    if (Test-Path -LiteralPath $script:TStylesCurrent) {
        Remove-Item -LiteralPath $script:TStylesCurrent -Force -ErrorAction SilentlyContinue
    }

    Write-Host ""
    Write-Host " Reset " -NoNewline
    Write-Host (Get-TerminalDisplayName -Kind $kind) -ForegroundColor Cyan -NoNewline
    Write-Host " to its unstyled default."
    Write-Host " Open a new tab to restore your default prompt."
    Write-Host ""
}

function Reset-StyleDirect {
    # `tstyles reset [-Target <name>]` -- revert a WT profile to its unstyled
    # default: strip the fields TerminalStyles writes, remove the now-orphan
    # color scheme, and clear current-style.ps1 (restore the user's prompt).
    # Inverse of Apply-StyleDirect. Writes a rolling .bak first.
    #
    # Off Windows Terminal there is no settings.json to strip: the reset is an
    # OSC 104/110-117 packet that hands color control back to the terminal's own
    # profile, plus dropping the style record and current-style.ps1.
    param([string]$Target)

    Show-UpdateNoticeIfAvailable

    if ((Get-TerminalKind) -ne 'WindowsTerminal') {
        Reset-StyleNonWT
        return
    }

    $settingsPath = Find-WTSettingsPath
    if (-not $settingsPath) {
        Write-Error "Could not locate Windows Terminal settings.json."
        return
    }

    $originalJson = [System.IO.File]::ReadAllText($settingsPath, [System.Text.UTF8Encoding]::new($false))
    $settings = ConvertFrom-WTJson $originalJson

    if (-not $Target) { $Target = Get-CurrentWTProfileName -Settings $settings }
    if (-not $Target) {
        Write-Error "Could not auto-detect a Windows Terminal profile to reset. Try: tstyles reset -Target '<name>'"
        return
    }

    # Rolling backup (same safety net as Apply-StyleDirect).
    $bakPath = "$settingsPath.bak"
    try {
        Copy-Item -LiteralPath $settingsPath -Destination $bakPath -Force -ErrorAction Stop
        Write-Host "Backed up settings to: $bakPath" -ForegroundColor Gray
    } catch {
        Write-Host "Warning: could not write backup ($_); proceeding anyway." -ForegroundColor Yellow
    }

    # Resolve the profile entry (same resolution as Merge-StyleIntoSettings).
    $entry = if ($Target -eq 'defaults') {
        if ($settings.profiles.PSObject.Properties.Match('defaults').Count) { $settings.profiles.defaults } else { $null }
    } else {
        $settings.profiles.list | Where-Object name -eq $Target | Select-Object -First 1
    }
    if (-not $entry) {
        Write-Host "Profile '$Target' not found in settings.json -- nothing to reset." -ForegroundColor Yellow
        return
    }

    # Capture the scheme name before stripping (for orphan cleanup).
    $schemeName = if ($entry.PSObject.Properties.Match('colorScheme').Count) { $entry.colorScheme } else { $null }

    # Strip every TerminalStyles field that is present on the entry.
    $strippedAny = $false
    foreach ($field in $script:TStylesThemeFields) {
        if ($entry.PSObject.Properties.Match($field).Count) {
            $entry.PSObject.Properties.Remove($field)
            $strippedAny = $true
        }
    }

    # Remove the orphan scheme unless another profile still references it.
    if ($schemeName -and $settings.PSObject.Properties.Match('schemes').Count) {
        $allProfiles = @()
        if ($settings.profiles.PSObject.Properties.Match('defaults').Count) { $allProfiles += $settings.profiles.defaults }
        $allProfiles += @($settings.profiles.list)
        $stillUsed = @($allProfiles | Where-Object {
            $_.PSObject.Properties.Match('colorScheme').Count -and $_.colorScheme -eq $schemeName
        }).Count -gt 0
        if (-not $stillUsed) {
            $settings.schemes = @($settings.schemes | Where-Object { $_.name -ne $schemeName })
        }
    }

    Write-SettingsFile -Path $settingsPath -Settings $settings

    # Clear the active style's prompt so the user's own prompt returns.
    if (Test-Path -LiteralPath $script:TStylesCurrent) {
        Remove-Item -LiteralPath $script:TStylesCurrent -Force
    }

    Write-Host ""
    if ($strippedAny) {
        Write-Host " Reset '$Target' to its unstyled default." -ForegroundColor Green
    } else {
        Write-Host " '$Target' had no TerminalStyles fields -- already plain." -ForegroundColor Gray
    }
    Write-Host " Open a new tab to restore your default prompt." -ForegroundColor DarkGray
    Write-Host ""
}