lib/creator.ps1

# The state behind `tstyles create`, and its live preview in Windows Terminal.
#
# The state is one hashtable the creator's pages read and change. The preview
# writes it through the same recorded session as the picker and the tuner
# (lib/previewsession.ps1), so Esc and a closed tab put settings.json back the
# same way, and through New-CreatedThemeObject, the writer Save-CreatedStyle
# is handed, so what the preview shows is what the saved style holds.

# The scheme's name while the creator is open. The style's own name is asked
# for only at the end, and a scheme named after it could replace one already
# in settings.json for as long as the preview is up.
$script:CreatorPreviewName = 'tstyles create'

function New-CreatorState {
    <#
    .SYNOPSIS
    The creator's starting state for one image: its palette, and every default.

    .DESCRIPTION
    Pixels are the ints Read-ImagePixels emitted, already read. Edited is the
    set of colour slots the user has set by hand; Update-CreatorPalette spares
    them. Name stays $null until the last page.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][int[]]$Pixels,
        [Parameter(Mandatory)][string]$ImagePath
    )
    $scheme = New-PaletteFromPixels -Pixels $Pixels -Name $script:CreatorPreviewName
    $accent = Get-PaletteAccent -Scheme $scheme
    if (-not $accent) { $accent = $scheme.foreground }
    @{
        Step              = 0
        ImagePath         = $ImagePath
        Pixels            = $Pixels
        Scheme            = $scheme
        Edited            = New-Object 'System.Collections.Generic.HashSet[string]'
        ImageOpacity      = 0.35
        StretchMode       = 'uniformToFill'
        Alignment         = 'center'
        FontFace          = 'Cascadia Code'
        FontSize          = 11
        CursorShape       = 'bar'
        PromptShape       = 'minimal'
        Symbol            = '$'
        PromptPathColor   = $accent
        PromptSymbolColor = $scheme.foreground
        Name              = $null
    }
}

function Get-CreatorColorSlot {
    # The scheme's own spelling of a colour slot, or $null. 'name' is not one.
    param([Parameter(Mandatory)]$Scheme, [Parameter(Mandatory)][string]$Slot)
    if ($Slot -eq 'name') { return $null }
    $p = @($Scheme.PSObject.Properties | Where-Object { $_.Name -eq $Slot })
    if ($p.Count -eq 0) { return $null }
    $p[0].Name
}

function Set-CreatorColor {
    <#
    .SYNOPSIS
    Set one colour slot by hand, and mark it edited.

    .DESCRIPTION
    Any readable colour is taken, a hard-to-read one included:
    Get-CreatorWarnings says so, nothing here refuses it. Throws on a slot the
    scheme does not have, or a value that is not a colour, and then changes
    nothing.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$State,
        [Parameter(Mandatory)][string]$Slot,
        [Parameter(Mandatory)][string]$Hex
    )
    $name = Get-CreatorColorSlot -Scheme $State.Scheme -Slot $Slot
    if (-not $name) { throw "'$Slot' is not a colour slot of this scheme." }
    $norm = ConvertTo-NormalHex $Hex
    if (-not $norm) { throw "'$Hex' is not a colour (expected #rrggbb)." }
    $State.Scheme.$name = $norm
    [void]$State.Edited.Add($name)
}

function Update-CreatorPalette {
    <#
    .SYNOPSIS
    Pull the palette from the state's pixels again, keeping the edited slots.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][hashtable]$State)
    $fresh = New-PaletteFromPixels -Pixels $State.Pixels -Name $script:CreatorPreviewName
    foreach ($p in $fresh.PSObject.Properties) {
        if ($p.Name -eq 'name' -or $State.Edited.Contains($p.Name)) { continue }
        if ($State.Scheme.PSObject.Properties.Match($p.Name).Count) {
            $State.Scheme.($p.Name) = $p.Value
        } else {
            $State.Scheme | Add-Member -NotePropertyName $p.Name -NotePropertyValue $p.Value
        }
    }
}

function Get-CreatorWarningSlots {
    <#
    .SYNOPSIS
    What will be hard to read, or will not show: @{ Unreadable; LowContrast }.

    .DESCRIPTION
    THE rule behind both renderings, Get-CreatorWarnings (one sentence each)
    and Get-CreatorWarningLine (the page's one line). Contrast against the
    background comes from Get-SchemeLowContrastSlots, the same bars the
    palette is pulled to (foreground 7:1), with the prompt's path and symbol
    colours held to the ANSI hues' 4.5:1 as 'prompt path colour' and 'prompt
    symbol colour'; a value that is not a colour from
    Get-SchemeUnreadableSlots, the slots the OSC packet would drop. Warnings
    only: the creator keeps the edit.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][hashtable]$State)
    $unreadable = @(Get-SchemeUnreadableSlots -Scheme $State.Scheme | Where-Object { $_ -ne 'name' })
    $text = [ordered]@{}
    if ($State.ContainsKey('PromptPathColor')) { $text['prompt path colour'] = [string]$State.PromptPathColor }
    if ($State.ContainsKey('PromptSymbolColor')) { $text['prompt symbol colour'] = [string]$State.PromptSymbolColor }
    $low = @(Get-SchemeLowContrastSlots -Scheme $State.Scheme -Text $text)
    @{ Unreadable = [string[]]$unreadable; LowContrast = [string[]]$low }
}

function Get-CreatorWarnings {
    <#
    .SYNOPSIS
    One line per colour that will be hard to read, or will not show at all.

    .DESCRIPTION
    Get-CreatorWarningSlots' answer, a sentence each. Empty when everything
    reads.
    #>

    [CmdletBinding()]
    [OutputType([string[]])]
    param([Parameter(Mandatory)][hashtable]$State)
    $w = Get-CreatorWarningSlots -State $State
    $lines = New-Object System.Collections.Generic.List[string]
    foreach ($k in $w.Unreadable) { $lines.Add(("{0} is not a colour, so it will not show." -f $k)) }
    foreach ($k in $w.LowContrast) { $lines.Add(("{0} is hard to read against the background." -f $k)) }
    # Callers wrap in @(): a bare return unrolls to nothing when empty.
    $lines.ToArray()
}

function Get-CreatorWarningLine {
    <#
    .SYNOPSIS
    Get-CreatorWarningSlots' answer as ONE line of at most -MaxLength
    characters, or $null when everything reads.

    .DESCRIPTION
    "Not a colour, will not show: cyan. Hard to read: red, green." One line
    whatever the count, so the page keeps its height; names that do not fit
    are counted ("... and 9 more.") rather than wrapped.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][hashtable]$State, [int]$MaxLength = 114)
    $w = Get-CreatorWarningSlots -State $State
    $groups = @(
        @{ Label = 'Not a colour, will not show'; Names = @($w.Unreadable) }
        @{ Label = 'Hard to read'; Names = @($w.LowContrast) }
    )
    $total = 0
    foreach ($g in $groups) { $total += $g.Names.Count }
    if ($total -eq 0) { return $null }
    $text = $null
    for ($keep = $total; $keep -ge 1; $keep--) {
        $left = $keep
        $parts = New-Object System.Collections.Generic.List[string]
        foreach ($g in $groups) {
            $n = [Math]::Min($left, $g.Names.Count)
            $left -= $n
            if ($n -gt 0) { $parts.Add(($g.Label + ': ' + (($g.Names[0..($n - 1)]) -join ', '))) }
        }
        $text = $parts -join '. '
        if ($keep -lt $total) { $text += (' and {0} more' -f ($total - $keep)) }
        $text += '.'
        if ($text.Length -le $MaxLength) { return $text }
    }
    $text
}

function New-CreatorPromptText {
    # The prompt a created style is saved with: New-CreatedStylePromptText
    # from the state's prompt choices -- the one rule the submission review
    # regenerates with too. The save calls this, and the parity test renders
    # what it returns, so the prompt measured is the prompt shipped.
    param([Parameter(Mandatory)][hashtable]$State, [Parameter(Mandatory)][string]$Name)
    New-CreatedStylePromptText -Choice (ConvertTo-CreatedPromptChoice -State $State) -Name $Name
}

function Get-CreatorTabColor {
    # The tab colour a created style gets: the palette's accent, else its
    # foreground, else grey. One rule, read by the preview and by the save, so
    # the tab the user saw is the tab they get.
    param([Parameter(Mandatory)]$Scheme)
    $tab = Get-PaletteAccent -Scheme $Scheme
    if (-not $tab) { $tab = ConvertTo-NormalHex $Scheme.foreground }
    if (-not $tab) { $tab = '#808080' }
    $tab
}

function Write-CreatorPreview {
    <#
    .SYNOPSIS
    Show the state in Windows Terminal: write the scratch style, merge it into
    the original settings.json text, and write that through the session.

    .DESCRIPTION
    Returns Write-FilePreview's status (written | current | changed | failed),
    or 'failed' when the scratch style cannot be built or the merge has
    nothing to write. Never throws. Every preview is built
    from -OriginalJson, never from the file as the last preview left it, so
    the record's copy of the original stays the only thing Esc needs.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][hashtable]$State,
        [AllowNull()][hashtable]$Session,
        [Parameter(Mandatory)][string]$ScratchDir,
        [Parameter(Mandatory)][string]$Target,
        [Parameter(Mandatory)][AllowEmptyString()][string]$OriginalJson
    )
    $utf8 = [System.Text.UTF8Encoding]::new($false)
    # Merge-StyleIntoSettings finds the scheme by theme.colorScheme, so both
    # carry the placeholder name.
    $scheme = [pscustomobject][ordered]@{}
    foreach ($p in $State.Scheme.PSObject.Properties) {
        $scheme | Add-Member -NotePropertyName $p.Name -NotePropertyValue $p.Value
    }
    $scheme | Add-Member -NotePropertyName name -NotePropertyValue $script:CreatorPreviewName -Force

    $tab = Get-CreatorTabColor -Scheme $scheme
    try {
        # Inside the try: it validates opacity and stretch mode, and a preview
        # never throws into the creator's key loop (Write-FilePreview's rule).
        $theme = New-CreatedThemeObject -Name $script:CreatorPreviewName -FontFace $State.FontFace `
            -FontSize $State.FontSize -CursorShape $State.CursorShape -ImageOpacity $State.ImageOpacity `
            -StretchMode $State.StretchMode -Alignment $State.Alignment -TabColor $tab
        if (-not [System.IO.Directory]::Exists($ScratchDir)) { [void][System.IO.Directory]::CreateDirectory($ScratchDir) }
        [System.IO.File]::WriteAllText((Join-Path $ScratchDir 'scheme.json'), ($scheme | ConvertTo-Json -Depth 16), $utf8)
        [System.IO.File]::WriteAllText((Join-Path $ScratchDir 'theme.json'), ($theme | ConvertTo-Json -Depth 16), $utf8)
        $text = Get-StylePreviewJson -OriginalJson $OriginalJson -StyleDir $ScratchDir -TargetName $Target `
            -BackgroundImage $State.ImagePath -BackgroundImageProvided $true
    } catch { return 'failed' }
    if ($null -eq $text) { return 'failed' }
    Write-FilePreview -Session $Session -Text $text
}

# ---- the command: `tstyles create` ------------------------------------------

function Get-CreatorConsoleRedirection {
    # Is either end of this session redirected? The creator's guards ask here
    # rather than reading [Console] themselves so a test can answer: the .NET
    # statics cannot be mocked, and on CI both are true.
    [pscustomobject]@{
        Input  = [bool][Console]::IsInputRedirected
        Output = [bool][Console]::IsOutputRedirected
    }
}

function Read-CreatorConsoleKey {
    # The next key, or $null when none is waiting. Never blocks: the loop
    # draws the page and writes the debounced preview only when it is idle,
    # so a pasted path is one redraw, not one per character.
    if (-not [Console]::KeyAvailable) { return $null }
    [Console]::ReadKey($true)
}

function Remove-StaleCreatorScratch {
    <#
    .SYNOPSIS
    Remove the scratch sessions creators that are no longer running left.

    .DESCRIPTION
    Each creator writes its preview style to $Root\session-<PID> and removes
    that folder on every way out its finally block sees. A closed tab is not
    one of them -- pwsh is killed without running finally -- so the folder
    stays. A session-<n> whose process is gone goes here; this process's own,
    one whose process is still running (a creator open in another tab), and
    anything not named session-<n> are left alone. Containment is proved
    before each recursive delete (Test-PathIsUnderRoot).
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Root)
    if (-not [System.IO.Directory]::Exists($Root)) { return }
    foreach ($d in [System.IO.Directory]::GetDirectories($Root)) {
        $leaf = [System.IO.Path]::GetFileName($d)
        if ($leaf -notmatch '^session-(\d{1,9})$') { continue }
        $n = [int]$Matches[1]
        if ($n -eq $PID) { continue }
        if (Get-Process -Id $n -ErrorAction SilentlyContinue) { continue }
        if (-not (Test-PathIsUnderRoot -Path $d -Root $Root)) { continue }
        Remove-Item -LiteralPath $d -Recurse -Force -ErrorAction SilentlyContinue
    }
}

function Invoke-TerminalStyleCreate {
    <#
    .SYNOPSIS
    `tstyles create`: build a new style step by step, previewed live in the
    Windows Terminal window it runs in.

    .DESCRIPTION
    The pages and keys are Invoke-CreatorKey's and Get-CreatorPageLines'
    (lib/creatorengine.ps1); this is the loop around them, built the way the
    tuner's is (Invoke-TerminalStyleTune): every guard before the screen is
    touched, a closed session's preview recovered before settings.json is
    read, the rolling .bak taken, colours over OSC at once and the rest
    through a debounced write of the recorded preview session, and the update
    notice held until the screen is given back.

    Esc puts settings.json back through the session and resets the colours.
    Enter on the last page saves through Save-CreatedStyle FIRST, with the
    preview still live: a name that is taken, invalid or fails to save keeps
    the user on that page, and Esc there still has everything to put back.
    Only a saved style ends the session -- the bytes the creator found go
    back, so the placeholder scheme never stays in settings.json -- and is
    then applied through Apply-StyleDirect, the apply `tstyles <name>` runs.
    #>

    [CmdletBinding()]
    param()

    # Held, and printed after the screen is given back -- see the tuner: its
    # Clear-Host takes the scrollback, and printing the notice burns the
    # throttle. Checked only past the guards.
    $pendingUpdate = $null

    # Windows Terminal only, for now. The image, font and cursor preview live
    # only through its settings.json; elsewhere a creator would show colours
    # and nothing else of a style that is mostly its image. Refused before
    # anything is read or written.
    $kind = Get-TerminalKind
    if ($kind -ne 'WindowsTerminal') {
        # Every line true for every kind: a terminal Get-TerminalDisplayName
        # does not know is not named (its fallback, "this terminal", would
        # say nothing), and the tuner's live colours are offered only where
        # Get-TerminalCapability promises an OSC palette.
        $display = Get-TerminalDisplayName -Kind $kind
        Write-Host ""
        Write-Host " tstyles create needs Windows Terminal for now." -ForegroundColor Yellow
        Write-Host " It previews the image, font and cursor through Windows Terminal's settings.json."
        if ($display -ne 'this terminal') { Write-Host (" This terminal is {0}." -f $display) }
        Write-Host " Nothing was changed."
        Write-Host ""
        if ((Get-TerminalCapability -Kind $kind).OscPalette) {
            Write-Host " Here, tstyles tune <name> adjusts an existing style, with its colours live." -ForegroundColor DarkGray
        }
        Write-Host " tstyles list shows the styles there are; tstyles <name> applies one." -ForegroundColor DarkGray
        Write-Host ""
        return
    }

    # Both console guards, before the recovery, the backup and the first
    # Clear-Host, for the reasons the tuner's carry: KeyAvailable throws on
    # redirected input, and with output redirected the page and every OSC
    # repaint go into the file while keystrokes are acted on blind.
    $redirect = Get-CreatorConsoleRedirection
    if ($redirect.Input) {
        Write-Host ""
        Write-Host " tstyles create needs an interactive terminal." -ForegroundColor Yellow
        Write-Host " This session's input is redirected, so there are no keystrokes to read. Nothing was changed."
        Write-Host ""
        return
    }
    if ($redirect.Output) {
        Write-Host ""
        Write-Host " tstyles create needs a terminal to repaint." -ForegroundColor Yellow
        Write-Host " This session's output is redirected, so there is nothing to preview against. Nothing was changed."
        Write-Host ""
        return
    }

    $settingsPath = Find-WTSettingsPath
    if (-not $settingsPath) { return }  # Find-WTSettingsPath has already said why.
    # Said now, and again at the exit the screen clear would erase.
    $recovered = Restore-AbandonedFilePreview -Kind WindowsTerminal -Path $settingsPath
    Show-FilePreviewResult -Result $recovered

    $pendingUpdate = Test-UpdateAvailable

    try {
        $session = New-FilePreviewSession -Kind WindowsTerminal -Path $settingsPath -By creator
    } catch {
        Write-Error $_.Exception.Message
        return
    }
    $originalSettings = ConvertFrom-JsoncText $session.OriginalText
    $target = Get-CurrentWTProfileName -Settings $originalSettings
    if (-not $target) {
        Write-Error "Could not auto-detect a Windows Terminal profile to preview against."
        return
    }
    $session.TargetName = $target

    # Crash-recovery copy before the first preview write. A failure is said
    # inside the frame: anything printed here is wiped by the first redraw.
    $backupNote = $null
    $resolved = Resolve-WTProfileTarget -Settings $originalSettings -TargetName $target
    try { Save-SettingsBackup -Path $settingsPath -ResolvedTarget $resolved -Quiet }
    catch { $backupNote = Get-BackupFailureNote -Reason "$_" -InFrame }

    # Per process, like the tuner's .tune-preview: two creators in two tabs
    # never share (or delete) one folder. Sessions a closed tab left go first.
    $scratchRoot = Join-Path $script:TStylesDataRoot '.create-preview'
    Remove-StaleCreatorScratch -Root $scratchRoot
    # And the half-built styles a tab closed mid-save left (over an hour old).
    Remove-StaleCreatingWork -Root (Join-Path $script:TStylesDataRoot '.creating')
    $scratchSession = Join-Path $scratchRoot ("session-{0}" -f $PID)
    $scratchDir = Join-Path $scratchSession 'style'

    $state = New-CreatorStartState
    $message = $null       # what the last key has to say, for the next frame
    $previewNote = $null   # a preview write that did not land
    $pendingPreview = $false
    $needsRedraw = $true
    $applied = $false
    $e = [char]27

    # Restore-TuneBaseLook is the tuner's put-back, and runs at most once per
    # session: settings.json through the session, then Get-RevertOscPacket's
    # packet (on Windows Terminal, the reset, so it repaints from the file).
    $createState = @{ Reverted = $false; SettingsRestoreError = $null; SettingsRestoreUnchecked = $false; SettingsRestore = $null }
    $restoreLook = { Restore-TuneBaseLook -State $createState -UseSettingsFile -Session $session }
    $restoreFailedHere = {
        $createState.SettingsRestoreError -and -not ($createState.SettingsRestore -and $createState.SettingsRestore.Arm -eq 'merge')
    }
    $showRestore = {
        $sr = $createState.SettingsRestore
        if ($sr -and ($sr.Status -ne 'failed' -or $sr.Arm -eq 'merge')) { Show-FilePreviewResult -Result $sr }
        Show-FilePreviewResult -Result $recovered
    }
    $showPendingUpdate = {
        if ($pendingUpdate) {
            Write-Host (" Update available ({0} -> {1}). Run: tstyles update" -f
                        $pendingUpdate.Installed, $pendingUpdate.Remote) -ForegroundColor Yellow
            Write-Host ""
        }
    }
    $draw = {
        # No blank line first: the page is held to 28 lines
        # (Get-CreatorPageLines), so with a note or two it still fits a
        # 30-row window without scrolling its title away.
        Clear-Host
        foreach ($note in @($backupNote, $previewNote, $message)) {
            if ($note) { Write-Host (" $e[33m! " + ([string]$note).Trim().TrimStart('!').Trim() + "$e[0m") }
        }
        # Assigned first, never @(): Get-CreatorPageLines emits its lines as
        # ONE array (a unary comma), and @() of a single array is a one-element
        # array holding it. Write-Host then got the whole page as one object --
        # joined with spaces onto one line on pwsh 7, and printed as
        # "System.String[]" on 5.1, whose remaining-arguments binding wraps it.
        $pageLines = Get-CreatorPageLines -State $state
        foreach ($line in $pageLines) { Write-Host $line }
    }

    $originalTitle = $null
    try { $originalTitle = $Host.UI.RawUI.WindowTitle } catch { }
    try { [Console]::CursorVisible = $false } catch { }
    try {
        while ($true) {
            $key = Read-CreatorConsoleKey
            if ($null -eq $key) {
                # Idle: draw first, so the page answers the key at once, then
                # the debounced write, which reloads Windows Terminal.
                $idleWork = $needsRedraw -or $pendingPreview
                if ($needsRedraw) { & $draw; $needsRedraw = $false }
                if ($pendingPreview) {
                    $pendingPreview = $false
                    $status = Write-CreatorPreview -State $state -Session $session -ScratchDir $scratchDir `
                        -Target $target -OriginalJson $session.OriginalText
                    $note = $null
                    if ($status -eq 'changed') {
                        $note = 'settings.json was changed by something else while tstyles create was open, so the image, font and cursor no longer preview here; the colours still do.'
                    } elseif ($status -eq 'failed') {
                        $note = 'The last change could not be written to settings.json, so the image, font and cursor may not show it; the colours do.'
                    }
                    if ($note -cne $previewNote) { $previewNote = $note; $needsRedraw = $true }
                }
                if (-not $idleWork) { Start-Sleep -Milliseconds 50 }
                continue
            }

            # A large image can hold the reader for seconds: say so first,
            # below the page, so it never looks frozen.
            $reading = Get-CreatorImageToRead -State $state -Key $key
            if ($reading) {
                Write-Host (" Reading {0}..." -f [System.IO.Path]::GetFileName($reading)) -ForegroundColor DarkGray
            }
            # Always the returned state: a newly read image is a new hashtable.
            $r = Invoke-CreatorKey -State $state -Key $key
            $state = $r.State
            $action = [string]$r.Action
            $needsRedraw = $true
            $message = $null
            if ($action -like 'error:*') { $message = $action.Substring(6); continue }

            if ($action -eq 'preview') {
                [void](Write-HostOscPacket -Packet (Get-SchemeOscPacket -Scheme $state.Scheme))
                $pendingPreview = $true
                continue
            }

            if ($action -eq 'cancel') {
                $revert = & $restoreLook
                Clear-Host
                $sr = $createState.SettingsRestore
                if ($revert -eq 'failed' -and (& $restoreFailedHere)) {
                    Show-SettingsRestoreFailure -Reason $createState.SettingsRestoreError -SettingsPath $settingsPath `
                        -By creator -BackupTaken:(-not $backupNote) -Unchecked:([bool]$createState.SettingsRestoreUnchecked)
                } elseif ($revert -ne 'failed' -and (-not $sr -or $sr.Status -eq 'restored')) {
                    Write-Host "Cancelled. Your terminal is back as it was." -ForegroundColor Yellow
                } else {
                    # The file changed while the creator was open, or another
                    # command took the session over: Show-FilePreviewResult
                    # says what was and was not put back.
                    Write-Host "Cancelled." -ForegroundColor Yellow
                }
                & $showRestore
                & $showPendingUpdate
                return
            }

            if ($action -eq 'save') {
                $name = [string]$state.Name
                # Saved with the preview still live, so a refusal leaves Esc
                # everything it needs.
                $res = $null
                try {
                    $prompt = New-CreatorPromptText -State $state -Name $name
                    $theme = New-CreatedThemeObject -Name $name -FontFace $state.FontFace -FontSize $state.FontSize `
                        -CursorShape $state.CursorShape -ImageOpacity $state.ImageOpacity `
                        -StretchMode $state.StretchMode -Alignment $state.Alignment `
                        -TabColor (Get-CreatorTabColor -Scheme $state.Scheme)
                    $res = Save-CreatedStyle -Name $name -Scheme $state.Scheme -Theme $theme `
                        -BackgroundPath $state.ImagePath -Prompt $prompt `
                        -PromptChoice (ConvertTo-CreatedPromptChoice -State $state)
                } catch {
                    $res = [pscustomobject]@{ Status = 'failed'; Path = $null; Reason = $_.Exception.Message }
                }
                if (-not $res) { $res = [pscustomobject]@{ Status = 'failed'; Path = $null; Reason = 'the save returned no result.' } }
                if ($res.Status -ne 'saved') {
                    $message = "Not saved: " + [string]$res.Reason
                    continue
                }

                # Saved. The preview comes out through the session -- the
                # bytes the creator found go back, or, in a file that changed,
                # the preview is taken out of it -- and the style is applied
                # from there, as the tuner does.
                $saveRestore = Restore-FilePreview -Session $session -End
                Clear-Host
                $applyErr = $null
                $applyThrew = $null
                try {
                    Apply-StyleDirect -StyleName $name -Target $target -ErrorVariable applyErr
                } catch { $applyThrew = $_ }
                # Only Apply-StyleDirect's own give-ups count (Write-Error +
                # return, before anything is written); see the tuner.
                $applied = (-not $applyThrew) -and
                    (-not @($applyErr | Where-Object { $_.CategoryInfo.Activity -eq 'Write-Error' }).Count)
                if ($applied) {
                    Remove-FilePreviewRecord -Kind WindowsTerminal -Path $settingsPath -SessionId $session.Id
                    Write-Host ("Created '{0}' and applied it." -f $name) -ForegroundColor Green
                    Write-Host ("To offer it for the catalogue: tstyles submit {0}" -f $name)
                } else {
                    if ($applyThrew) { Write-Host (" " + $applyThrew.Exception.Message) -ForegroundColor Red }
                    Write-Host ("Created '{0}', but it could not be applied. It is saved in {1}; to apply it: tstyles {0}" -f
                                $name, $res.Path) -ForegroundColor Yellow
                }
                Show-FilePreviewResult -Result $saveRestore
                Show-FilePreviewResult -Result $recovered
                & $showPendingUpdate
                return
            }
            # none | next | back: the page changed, and is redrawn when idle.
        }
    } finally {
        try { [Console]::CursorVisible = $true } catch { }
        if (-not $applied) {
            # The safety net for anything that left the loop another way. After
            # an Esc this answers 'already' and writes nothing.
            if (Test-ShouldRestoreWindowTitle -Title $originalTitle) {
                try { $Host.UI.RawUI.WindowTitle = $originalTitle } catch { }
            }
            $finalRevert = $null
            try { $finalRevert = & $restoreLook } catch { }
            if ($finalRevert -eq 'failed' -and (& $restoreFailedHere)) {
                Show-SettingsRestoreFailure -Reason $createState.SettingsRestoreError -SettingsPath $settingsPath `
                    -By creator -BackupTaken:(-not $backupNote) -Unchecked:([bool]$createState.SettingsRestoreUnchecked)
            }
        }
        if (Test-Path -LiteralPath $scratchSession) {
            if (Test-PathIsUnderRoot -Path $scratchSession -Root $scratchRoot) {
                Remove-Item -LiteralPath $scratchSession -Recurse -Force -ErrorAction SilentlyContinue
            }
        }
    }
}