lib/tune.ps1
|
# tune.ps1 -- `tstyles tune`: the live editor and the style it materialises. # # Dot-sourced by tstyles.ps1, so everything here shares its $script: scope. # # Five knobs, split by what each can actually do. Brightness and saturation # retint live over OSC on every terminal. Opacity and font can only be previewed # on Windows Terminal, by writing a scratch style and running the REAL merge # against it -- so the preview cannot drift from an apply. Off Windows Terminal # they are recorded in the saved style and nothing there can show them. # # Save-TunedStyle materialises a full style, not a diff: it lands in the user # styles dir and is thereafter indistinguishable from a hand-written one. function New-TunedThemeObject { # Builds the theme.json object for a tuned style: base theme.json (or {}) # with colorScheme/opacity/font overridden. Preserves font.weight and the # backgroundImage placeholder. Shared by Save-TunedStyle and the live # preview so the saved result matches what was previewed. param( [Parameter(Mandatory)][string]$BaseStyleDir, [Parameter(Mandatory)][string]$ColorScheme, [int]$Opacity, [string]$FontFace, [int]$FontSize ) $themeSrc = Join-Path $BaseStyleDir 'theme.json' $theme = if (Test-Path -LiteralPath $themeSrc) { [System.IO.File]::ReadAllText($themeSrc, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json } else { [pscustomobject]@{} } $theme | Add-Member -NotePropertyName colorScheme -NotePropertyValue $ColorScheme -Force $theme | Add-Member -NotePropertyName opacity -NotePropertyValue $Opacity -Force $font = if ($theme.PSObject.Properties.Match('font').Count) { $theme.font } else { [pscustomobject]@{} } $font | Add-Member -NotePropertyName face -NotePropertyValue $FontFace -Force $font | Add-Member -NotePropertyName size -NotePropertyValue $FontSize -Force $theme | Add-Member -NotePropertyName font -NotePropertyValue $font -Force return $theme } function Save-TunedStyle { # Materializes a tuned style into $DataRoot\styles\$SaveName\: # scheme.json (adjusted colors, name = $SaveName) # theme.json (base theme + colorScheme/opacity/font overrides) # profile.ps1 (copied from base if present) # tune.json (deltas + base name, for re-tuning) param( [Parameter(Mandatory)]$AdjustedScheme, [Parameter(Mandatory)][string]$SaveName, [Parameter(Mandatory)][string]$BaseStyleDir, [Parameter(Mandatory)][string]$BaseName, [int]$Brightness, [int]$Saturation, [int]$Opacity, [string]$FontFace, [int]$FontSize ) $destDir = Join-Path (Join-Path $script:TStylesDataRoot 'styles') $SaveName if (-not (Test-Path -LiteralPath $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null } # Clone so the caller's object is not mutated (matches Get-AdjustedScheme's contract). $scheme = $AdjustedScheme.PSObject.Copy() $scheme | Add-Member -NotePropertyName name -NotePropertyValue $SaveName -Force [System.IO.File]::WriteAllText( (Join-Path $destDir 'scheme.json'), ($scheme | ConvertTo-Json -Depth 16), [System.Text.UTF8Encoding]::new($false)) $theme = New-TunedThemeObject -BaseStyleDir $BaseStyleDir -ColorScheme $SaveName ` -Opacity $Opacity -FontFace $FontFace -FontSize $FontSize [System.IO.File]::WriteAllText( (Join-Path $destDir 'theme.json'), ($theme | ConvertTo-Json -Depth 16), [System.Text.UTF8Encoding]::new($false)) # Re-tuning a style that was already saved with Overwrite makes $BaseStyleDir # and $destDir the SAME directory, so every "copy from base to dest" below is # really a copy of a file onto itself. Left unguarded that appends a second # `# tstyles-tuned:` marker to profile.ps1 on each repeat and makes Copy-Item # throw a red "Cannot overwrite the item with itself" for prompt.sh, at save # time, after the user has already committed. # Compared the way the HOST FILESYSTEM compares, not the way PowerShell's # -eq does. -eq is case-insensitive, so on Linux (case-sensitive) a "Save # as" of `Eva` from a base at styles/eva would look like the same directory # and skip the prompt.sh copy into what is really a brand-new style -- # costing it the zsh/bash prompt the copy exists to provide. $sameDir = $false try { $cmp = if ((Get-TStylesPlatform) -eq 'Linux') { [System.StringComparison]::Ordinal } else { [System.StringComparison]::OrdinalIgnoreCase } $sep = [System.IO.Path]::DirectorySeparatorChar $sameDir = [string]::Equals( [System.IO.Path]::GetFullPath($destDir).TrimEnd($sep), [System.IO.Path]::GetFullPath($BaseStyleDir).TrimEnd($sep), $cmp) } catch { $sameDir = $false } $profileSrc = Join-Path $BaseStyleDir 'profile.ps1' if (Test-Path -LiteralPath $profileSrc) { # Append a per-style marker so the materialized profile is byte-distinct # from the base's (we copied it verbatim). Without this, Get-CurrentStyleName # -- which byte-compares current-style.ps1 against each style's profile.ps1 # and returns the alphabetically-first match -- would attribute this tuned # style to its base. The marker is an inert comment (profile is dot-sourced). $profileContent = [System.IO.File]::ReadAllText($profileSrc, [System.Text.UTF8Encoding]::new($false)) # Strip any marker a previous save left, so re-tuning appends exactly one # rather than accumulating a line per save. Idempotent either way. $profileContent = [regex]::Replace($profileContent, '(\r?\n)?^[ \t]*#[ \t]*tstyles-tuned:.*$', '', 'Multiline').TrimEnd("`r", "`n") $marker = [Environment]::NewLine + "# tstyles-tuned: $SaveName" + [Environment]::NewLine [System.IO.File]::WriteAllText( (Join-Path $destDir 'profile.ps1'), ($profileContent + $marker), [System.Text.UTF8Encoding]::new($false)) } # The zsh/bash prompt, copied verbatim. It needs no per-style marker: unlike # profile.ps1, nothing byte-compares it to identify the active style. Without # this a tuned style has no shell prompt at all, so a zsh user who tunes # anything silently loses the banner and prompt while keeping the colors. $promptSrc = Join-Path $BaseStyleDir 'prompt.sh' if (-not $sameDir -and (Test-Path -LiteralPath $promptSrc)) { Copy-Item -LiteralPath $promptSrc -Destination (Join-Path $destDir 'prompt.sh') -Force } $tune = [pscustomobject]@{ schemaVersion = 1 base = $BaseName brightness = $Brightness saturation = $Saturation opacity = $Opacity fontFace = $FontFace fontSize = $FontSize } [System.IO.File]::WriteAllText( (Join-Path $destDir 'tune.json'), ($tune | ConvertTo-Json -Depth 8), [System.Text.UTF8Encoding]::new($false)) return $destDir } function Get-TunedBaseBackground { # If $StyleDir is a tuned style (tune.json with a 'base'), resolve and # return the base style's background (with the base's own lazy-fetch). # $null if not tuned / base missing / base has no background. Strictly one # hop: the base is resolved with -NoInherit, so cyclic tune.json (A->B->A) # cannot recurse. param([Parameter(Mandatory)][string]$StyleDir) $tuneFile = Join-Path $StyleDir 'tune.json' if (-not (Test-Path -LiteralPath $tuneFile)) { return $null } try { $tune = [System.IO.File]::ReadAllText($tuneFile, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json } catch { return $null } if (-not $tune.base) { return $null } $baseDir = Get-StyleDir -StyleName $tune.base if (-not $baseDir -or ($baseDir -eq $StyleDir)) { return $null } return Get-StyleBundledBackground -StyleDir $baseDir -NoInherit } function Resolve-TuneSeed { # Computes the working base style + the initial knob values for `tstyles tune`. # Extracted from Invoke-TerminalStyleTune so the seeding rules are unit-testable. # # Rules: # * A tuned style (tune.json with a base that still resolves) seeds the base # dir + the recorded deltas, so re-deriving never double-applies onto the # already-baked scheme. # * Otherwise (a plain style, OR a tuned style whose recorded base was # deleted/renamed) the working base is the style itself, and opacity/font # seed from that style's OWN theme.json -- so re-tuning preserves the saved # values instead of snapping back to the hardcoded defaults. param( [Parameter(Mandatory)][string]$StyleName, [Parameter(Mandatory)][string]$StyleDir ) $seed = [pscustomobject]@{ BaseName = $StyleName; BaseDir = $StyleDir Brightness = 0; Saturation = 0; Opacity = 100; FontFace = $null; FontSize = 12 } $seededFromTune = $false $tuneFile = Join-Path $StyleDir 'tune.json' if (Test-Path -LiteralPath $tuneFile) { try { $tune = [System.IO.File]::ReadAllText($tuneFile, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json if ($tune.base) { $resolvedBaseDir = Get-StyleDir -StyleName $tune.base # Guard against a self-referential base (an Overwrite save writes # the baked scheme back onto the base name and records base = that # name). Re-seeding the deltas here would re-apply them on top of # the already-baked scheme -- color drift on every re-tune. Fall # through to own-theme.json seeding (neutral brightness/saturation) # instead. Mirrors the self-reference guard in Get-TunedBaseBackground. if ($resolvedBaseDir -and $resolvedBaseDir -ne $StyleDir) { $seed.BaseName = $tune.base $seed.BaseDir = $resolvedBaseDir $seed.Brightness = [int]$tune.brightness $seed.Saturation = [int]$tune.saturation $seed.Opacity = [int]$tune.opacity $seed.FontFace = [string]$tune.fontFace $seed.FontSize = [int]$tune.fontSize $seededFromTune = $true } } } catch { } } # When NOT seeded from a resolvable tune, take opacity/font from the working # base's own theme.json (the base for a plain style; the style itself for a # tuned style whose base vanished). if (-not $seededFromTune) { $baseThemePath = Join-Path $seed.BaseDir 'theme.json' if (Test-Path -LiteralPath $baseThemePath) { try { $baseTheme = [System.IO.File]::ReadAllText($baseThemePath, [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json if ($baseTheme.PSObject.Properties.Match('opacity').Count) { $seed.Opacity = [int]$baseTheme.opacity } if ($baseTheme.PSObject.Properties.Match('font').Count) { if ($baseTheme.font.PSObject.Properties.Match('face').Count) { $seed.FontFace = [string]$baseTheme.font.face } if ($baseTheme.font.PSObject.Properties.Match('size').Count) { $seed.FontSize = [int]$baseTheme.font.size } } } catch { } } } return $seed } function Invoke-TerminalStyleTune { # `tstyles tune [name]` -- interactive live tuning of a style's brightness, # saturation, opacity, font face, and font size. Colors retint instantly # via OSC; opacity/font ride a debounced settings.json write (through a # scratch style dir reusing Merge-StyleIntoSettings). Esc reverts byte- # exact; Enter prompts Save / Save As into the user-styles dir. [CmdletBinding()] param([string]$StyleName) Show-UpdateNoticeIfAvailable # Windows Terminal previews every knob live, by merging a scratch style into # settings.json and letting WT reload. Elsewhere only the color knobs can be # previewed -- brightness and saturation ride the OSC packet, while opacity # and font can reach the terminal only through a profile, which applies to a # new window rather than this one. # # That is a reduced tuner, not a broken one: the color work is the part # people actually sit and adjust, and the font/opacity values are still # saved with the style. So run it, and say up front which knobs will not # move on screen -- earlier versions refused outright here, which was more # conservative than the facts warranted. $tuneKind = Get-TerminalKind $tuneUsesSettings = ($tuneKind -eq 'WindowsTerminal') # --- Resolve the style (all guards run BEFORE any console interaction) --- if (-not $StyleName) { $StyleName = Get-CurrentStyleName if (-not $StyleName) { Write-Error "No active style detected. Try: tstyles tune <name>" return } } $styleDir = Get-StyleDir -StyleName $StyleName if (-not $styleDir) { Write-Error "Style '$StyleName' not found. Run 'tstyles list' to see available styles." return } $settingsPath = $null if ($tuneUsesSettings) { $settingsPath = Find-WTSettingsPath if (-not $settingsPath) { Write-Error "Could not locate Windows Terminal settings.json." return } } # --- Establish working base scheme + seed knob values --- # If this style is itself a tuned style (has tune.json), the working base # is its recorded base's PRISTINE scheme, and knobs seed from the deltas # (so re-deriving never double-applies onto the baked file). Otherwise the # working base is this style's own scheme with neutral knobs. $seed = Resolve-TuneSeed -StyleName $StyleName -StyleDir $styleDir $baseName = $seed.BaseName $baseDir = $seed.BaseDir $brightness = $seed.Brightness $saturation = $seed.Saturation $opacity = $seed.Opacity $fontFace = $seed.FontFace $fontSize = $seed.FontSize $baseScheme = [System.IO.File]::ReadAllText((Join-Path $baseDir 'scheme.json'), [System.Text.UTF8Encoding]::new($false)) | ConvertFrom-Json $fontList = @(Get-MonospaceFontList -Current $fontFace) if (-not $fontFace) { $fontFace = $fontList[0] } $fontIdx = [Math]::Max(0, [array]::IndexOf($fontList, $fontFace)) # --- Target WT profile --- $originalJson = $null $target = $null if ($tuneUsesSettings) { $originalJson = [System.IO.File]::ReadAllText($settingsPath, [System.Text.UTF8Encoding]::new($false)) $originalSettings = ConvertFrom-WTJson $originalJson $target = Get-CurrentWTProfileName -Settings $originalSettings if (-not $target) { Write-Error "Could not auto-detect a Windows Terminal profile to preview against." return } if (-not $env:WT_SESSION) { Write-Host "Note: live preview is only visible inside Windows Terminal." -ForegroundColor Yellow } } else { # Set expectations before the screen is taken over: the two color knobs # move on screen, the other three are only recorded. # # They are recorded and nothing more. Off Windows Terminal the only # writer is New-AppleTerminalProfile, whose profile carries colors and a # background image -- no font, no opacity (see Get-AppleTerminalProfileData). # Every other terminal gets escape sequences, which cannot carry either. # So promising "a new window shows them" sent the user to open one and # compare an unchanged font against the screenshot. Write-Host "" Write-Host (" Tuning on {0}: brightness and saturation preview live." -f (Get-TerminalDisplayName -Kind $tuneKind)) -ForegroundColor DarkGray Write-Host " Opacity and font are saved with the style, but this terminal cannot show them." -ForegroundColor DarkGray Write-Host "" Start-Sleep -Milliseconds 900 } # --- Scratch dir for the debounced settings.json preview (reuses Merge) --- $scratchDir = Join-Path (Join-Path $script:TStylesDataRoot '.tune-preview') $baseName if (-not (Test-Path -LiteralPath $scratchDir)) { New-Item -ItemType Directory -Path $scratchDir -Force | Out-Null } # Knob model: index 0..4 = brightness, saturation, opacity, font face, size. $sel = 0 $confirmed = $false $applied = $false # set true only after a saved style is applied; gates the revert $pendingApply = $false # the explicit `& $writePreview` below seeds the preview; this gates only knob edits $hint = "$([char]27)[38;2;160;160;160m" $reset = "$([char]27)[0m" # Recompute the adjusted scheme + cache its OSC packet. $adjusted = Get-AdjustedScheme -Scheme $baseScheme -Brightness $brightness -Saturation $saturation $oscPacket = Get-SchemeOscPacket -Scheme $adjusted $writePreview = { # Write scratch scheme/theme then merge into settings.json (brings # opacity/font/background live). Colors are already shown via OSC. $adj = Get-AdjustedScheme -Scheme $baseScheme -Brightness $brightness -Saturation $saturation $adj | Add-Member -NotePropertyName name -NotePropertyValue $baseScheme.name -Force [System.IO.File]::WriteAllText((Join-Path $scratchDir 'scheme.json'), ($adj | ConvertTo-Json -Depth 16), [System.Text.UTF8Encoding]::new($false)) $theme = New-TunedThemeObject -BaseStyleDir $baseDir -ColorScheme $baseScheme.name ` -Opacity $opacity -FontFace $fontFace -FontSize $fontSize [System.IO.File]::WriteAllText((Join-Path $scratchDir 'theme.json'), ($theme | ConvertTo-Json -Depth 16), [System.Text.UTF8Encoding]::new($false)) # tune.json so background inheritance resolves the base GIF in preview. [System.IO.File]::WriteAllText((Join-Path $scratchDir 'tune.json'), ('{"base":"' + $baseName + '"}'), [System.Text.UTF8Encoding]::new($false)) # The scratch style above is written either way -- Save-TunedStyle reads # it on Enter. Only the settings.json merge is Windows-Terminal-only. if (-not $tuneUsesSettings) { return } $preview = ConvertFrom-WTJson $originalJson $preview = Merge-StyleIntoSettings -Settings $preview -StyleDir $scratchDir ` -TargetName $target -BackgroundImage '' -BackgroundImageProvided $false Write-SettingsAtomic -Path $settingsPath -Json ($preview | ConvertTo-Json -Depth 100) } $bar = { param([int]$Value, [int]$Min, [int]$Max) $width = 10 $pos = [int][Math]::Round(($Value - $Min) / [double]($Max - $Min) * ($width - 1)) $pos = [Math]::Max(0, [Math]::Min($width - 1, $pos)) $cells = (1..$width | ForEach-Object { if (($_ - 1) -eq $pos) { 'o' } else { '-' } }) -join '' return "[$cells]" } $drawMenu = { Clear-Host Write-Host "" Write-Host " Tuning " -NoNewline Write-Host "'$StyleName'" -ForegroundColor Cyan -NoNewline Write-Host " base: $baseName" -ForegroundColor DarkGray Write-Host "$hint Up/Down select Left/Right adjust R reset color Enter save Esc cancel$reset" Write-Host "" $rows = @( @{ Label = 'Brightness'; Display = (& $bar $brightness -100 100); Value = ('{0:+#;-#;0}' -f $brightness) }, @{ Label = 'Saturation'; Display = (& $bar $saturation -100 100); Value = ('{0:+#;-#;0}' -f $saturation) }, @{ Label = 'Opacity'; Display = (& $bar $opacity 0 100); Value = "$opacity%" }, @{ Label = 'Font face'; Display = "< $fontFace >"; Value = '' }, @{ Label = 'Font size'; Display = (& $bar $fontSize 6 36); Value = "$fontSize" } ) for ($i = 0; $i -lt $rows.Count; $i++) { $color = if ($i -eq $sel) { 'Yellow' } else { 'Gray' } $prefix = if ($i -eq $sel) { ' > ' } else { ' ' } Write-Host ($prefix + ('{0,-12} ' -f $rows[$i].Label) + ('{0,-14} ' -f $rows[$i].Display) + $rows[$i].Value) -ForegroundColor $color } Write-Host "" Write-Host " Preview " -NoNewline Write-Host (Get-SchemeSwatch -Scheme $adjusted) Write-Host "" } # On-disk rolling backup before any preview write, so a hard kill mid-tune # leaves a recoverable copy (the in-memory $originalJson snapshot dies with # the process). Same rolling .bak the direct-apply/reset paths write. # Crash-recovery copy -- only meaningful where a settings.json is written. if ($tuneUsesSettings) { try { [System.IO.File]::WriteAllText("$settingsPath.bak", $originalJson, [System.Text.UTF8Encoding]::new($false)) } catch { } } [Console]::CursorVisible = $false $originalTitle = $Host.UI.RawUI.WindowTitle # Undo the live preview and put the terminal back the way it was found. # # Get-OscResetPacket hands colour control to the TERMINAL's own defaults, # which is right on Windows Terminal -- settings.json has just been restored # and WT repaints from it. Off Windows Terminal there is no such file: the # style being tuned was itself only escape sequences, so resetting drops the # user to a stock palette instead of the style they opened the tuner on. # Re-emit the unmodified base scheme there. Same fix, and the same reasoning, # as the picker's Esc. $restoreBaseLook = { if ($tuneUsesSettings) { Write-SettingsAtomic -Path $settingsPath -Json $originalJson } if (-not $tuneUsesSettings -and $baseScheme) { [Console]::Out.Write((Get-SchemeOscPacket -Scheme $baseScheme)) } else { [Console]::Out.Write((Get-OscResetPacket)) } } $needsRedraw = $true try { & $writePreview # initial preview from seeds [Console]::Out.Write($oscPacket) while (-not $confirmed) { if ($needsRedraw) { & $drawMenu; $needsRedraw = $false } if ([Console]::KeyAvailable) { $key = [Console]::ReadKey($true) $isColor = ($sel -eq 0 -or $sel -eq 1) switch ($key.Key) { 'UpArrow' { if ($sel -gt 0) { $sel-- }; $needsRedraw = $true; continue } 'DownArrow' { if ($sel -lt 4) { $sel++ }; $needsRedraw = $true; continue } 'LeftArrow' { switch ($sel) { 0 { $brightness = [Math]::Max(-100, $brightness - 5) } 1 { $saturation = [Math]::Max(-100, $saturation - 5) } 2 { $opacity = [Math]::Max(0, $opacity - 5) } 3 { $fontIdx = ($fontIdx - 1 + $fontList.Count) % $fontList.Count; $fontFace = $fontList[$fontIdx] } 4 { $fontSize = [Math]::Max(6, $fontSize - 1) } } if ($isColor) { $adjusted = Get-AdjustedScheme -Scheme $baseScheme -Brightness $brightness -Saturation $saturation $oscPacket = Get-SchemeOscPacket -Scheme $adjusted [Console]::Out.Write($oscPacket) } $pendingApply = $true; $needsRedraw = $true; continue } 'RightArrow' { switch ($sel) { 0 { $brightness = [Math]::Min(100, $brightness + 5) } 1 { $saturation = [Math]::Min(100, $saturation + 5) } 2 { $opacity = [Math]::Min(100, $opacity + 5) } 3 { $fontIdx = ($fontIdx + 1) % $fontList.Count; $fontFace = $fontList[$fontIdx] } 4 { $fontSize = [Math]::Min(36, $fontSize + 1) } } if ($isColor) { $adjusted = Get-AdjustedScheme -Scheme $baseScheme -Brightness $brightness -Saturation $saturation $oscPacket = Get-SchemeOscPacket -Scheme $adjusted [Console]::Out.Write($oscPacket) } $pendingApply = $true; $needsRedraw = $true; continue } 'R' { $brightness = 0; $saturation = 0 $adjusted = Get-AdjustedScheme -Scheme $baseScheme -Brightness 0 -Saturation 0 $oscPacket = Get-SchemeOscPacket -Scheme $adjusted [Console]::Out.Write($oscPacket) $pendingApply = $true; $needsRedraw = $true; continue } 'Enter' { if ($pendingApply) { & $writePreview; $pendingApply = $false }; $confirmed = $true; continue } 'Escape' { & $restoreBaseLook Clear-Host Write-Host "Reverted." -ForegroundColor Yellow return } } continue } if ($pendingApply) { $pendingApply = $false; & $writePreview; continue } Start-Sleep -Milliseconds 50 } # --- Confirmed: Save / Save As prompt --- Clear-Host Write-Host "" Write-Host " Save tuned '$StyleName'?" -ForegroundColor Cyan Write-Host " [1] Overwrite '$StyleName' (shadows the bundled style)" Write-Host " [2] Save as a new name" Write-Host "" $choice = (Read-Host " Choose [1/2]").Trim() $saveName = $null if ($choice -eq '1') { $saveName = $StyleName } else { while (-not $saveName) { $candidate = (Read-Host " New style name").Trim() if (-not $candidate) { Write-Host " Cancelled." -ForegroundColor Gray; break } if ($candidate -notmatch '^[A-Za-z0-9._-]+$') { Write-Host " Use letters, digits, dot, underscore, or hyphen only." -ForegroundColor Yellow continue } # Two different collisions, and only one of them loses work. # # A USER style of the same name is in the directory Save-TunedStyle # writes to, so saving over it destroys it -- and that was the case # with no warning at all. A BUNDLED style of the same name is only # shadowed: the original stays in the module and comes back if the # user style is deleted. The prompt used to warn about the harmless # one and stay silent about the destructive one. $userDir = Join-Path (Join-Path $script:TStylesDataRoot 'styles') $candidate if (Test-Path -LiteralPath (Join-Path $userDir 'scheme.json')) { $warn = (Read-Host " '$candidate' already exists and will be REPLACED. Continue? [y/N]").Trim() if ($warn -notmatch '^(?i)y') { continue } } else { $bundledDir = Join-Path (Join-Path $script:TStylesModuleRoot 'styles') $candidate if (Test-Path -LiteralPath (Join-Path $bundledDir 'scheme.json')) { $warn = (Read-Host " That shadows bundled '$candidate'. Continue? [y/N]").Trim() if ($warn -notmatch '^(?i)y') { continue } } } $saveName = $candidate } } if (-not $saveName) { # Treat an aborted save like a cancel: revert and exit. & $restoreBaseLook Write-Host " Reverted (nothing saved)." -ForegroundColor Yellow return } # Bake + persist, then apply the saved style from a clean settings.json # so no preview-scheme pollution lingers. $finalScheme = Get-AdjustedScheme -Scheme $baseScheme -Brightness $brightness -Saturation $saturation Save-TunedStyle -AdjustedScheme $finalScheme -SaveName $saveName ` -BaseStyleDir $baseDir -BaseName $baseName ` -Brightness $brightness -Saturation $saturation -Opacity $opacity ` -FontFace $fontFace -FontSize $fontSize | Out-Null if ($tuneUsesSettings) { Write-SettingsAtomic -Path $settingsPath -Json $originalJson } # -Target is a Windows Terminal profile name; off WT it is $null and the # apply resolves the host terminal itself. # # $applied gates the finally block's revert, so it has to mean "the style # really went on", not "we reached this line". Apply-StyleDirect's failure # modes are all Write-Error, which is non-terminating: setting $applied # unconditionally meant a failed apply skipped the OSC reset and left the # tuner's preview colors painted over an already-restored settings.json. # Filtered on Activity, not just "any error": Apply-StyleDirect's three # give-up paths are Write-Error + return, and all three return before # touching anything. An incidental non-terminating error from a cmdlet # further in (the Copy-Item/Remove-Item that install current-style.ps1) # means the style DID go on, so counting it as failure would revert a # good apply -- the opposite bug. $applyErr = $null if ($tuneUsesSettings) { Apply-StyleDirect -StyleName $saveName -Target $target -ErrorVariable applyErr } else { Apply-StyleDirect -StyleName $saveName -ErrorVariable applyErr } $applied = -not @($applyErr | Where-Object { $_.CategoryInfo.Activity -eq 'Write-Error' }).Count } finally { [Console]::CursorVisible = $true if (-not $applied) { # Safety net: restore settings.json + title unless a saved style was # applied. Esc / aborted-save already reverted explicitly; this also # covers an exception thrown mid-session (the key loop is not tested). # $restoreBaseLook may not exist yet if something threw very early. if ($restoreBaseLook) { & $restoreBaseLook } $Host.UI.RawUI.WindowTitle = $originalTitle } if (Test-Path -LiteralPath $scratchDir) { Remove-Item -LiteralPath $scratchDir -Recurse -Force -ErrorAction SilentlyContinue } } } |