lib/color.ps1
|
# color.ps1 -- HSL colour maths and the OSC packets built from it. # # Dot-sourced by tstyles.ps1, so everything here shares its $script: scope. # # The maths is deliberately simple and was verified exhaustively: an identity # sweep over all 16,777,216 colours and ~970k knob combinations produced no # malformed output. Brightness is ADDITIVE in L and saturation MULTIPLICATIVE in # S -- they are not symmetric, and the tuner's sliders depend on that. # # Get-SchemeOscPacket is the other half: the same scheme rendered as escape # sequences, which is how every terminal except Windows Terminal is themed. function ConvertTo-NormalHex { <# .SYNOPSIS Canonical `#rrggbb` for any hex colour this project can carry, else $null. .DESCRIPTION Accepts `#rgb`, `#rrggbb` and `#rrggbbaa` (with or without the leading `#`) and returns lowercase `#rrggbb`. Alpha is dropped: no consumer here can carry it -- OSC has nowhere to put it and Windows Terminal's scheme slots are six-digit. Two slots used to disagree about what counts as a colour. Get-AdjustedScheme tested `^#?[0-9a-fA-F]{6}$`, so a shorthand `#013` was passed through the tuner UNCHANGED while its neighbours brightened -- and `#RGB` is valid XParseColor, so the terminal genuinely applied the frozen value and it genuinely refused to move with the knob. Get-SchemeSwatch tested a stricter `^[0-9a-f]{6}$`, so it dropped the slot from the preview row and quietly substituted a different one, hiding the fact from the only colour feedback the tuner shows. A save then baked the frozen value in permanently. #> [CmdletBinding()] param([Parameter(Mandatory)][AllowEmptyString()][AllowNull()]$Hex) if ($null -eq $Hex) { return $null } $h = ([string]$Hex).Trim().TrimStart('#') if ($h -notmatch '^[0-9a-fA-F]+$') { return $null } switch ($h.Length) { 3 { return ('#{0}{0}{1}{1}{2}{2}' -f $h[0], $h[1], $h[2]).ToLowerInvariant() } 6 { return "#$($h.ToLowerInvariant())" } 8 { return "#$($h.Substring(0, 6).ToLowerInvariant())" } default { return $null } } } function Get-SchemeSwatch { # Returns a one-line ANSI swatch (up to 5 colored blocks) summarising a # theme. Picks slots that actually distinguish themes from each other -- # background, foreground, cursor accent, and two anchor ANSI hues -- and # falls back to other ANSI slots when those duplicate (e.g. sober has # cursorColor == foreground, eva has cursorColor == brightRed, which # would otherwise show the same color twice). Renders each slot as a # background-colored cell so even near-black colors stay visible against # the terminal background. Trailing reset. param([Parameter(Mandatory)]$Scheme) # Primary picks first, then fallbacks in order of theme-distinguishing # power. The first 5 unique hex values from this list are rendered. $candidates = @( $Scheme.background, $Scheme.foreground, $Scheme.cursorColor, $Scheme.brightRed, $Scheme.brightCyan, $Scheme.selectionBackground, $Scheme.brightPurple, $Scheme.brightYellow, $Scheme.brightGreen, $Scheme.brightBlue ) $seen = @{} $picks = @() foreach ($hex in $candidates) { if ($picks.Count -ge 5) { break } if (-not $hex) { continue } # Same notion of "a colour" the adjuster uses, so a slot cannot be # silently dropped from the preview while it is being adjusted. $norm = ConvertTo-NormalHex -Hex $hex if (-not $norm) { continue } $key = $norm.TrimStart('#') if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true $picks += $norm } $sb = New-Object System.Text.StringBuilder foreach ($hex in $picks) { $h = ([string]$hex).TrimStart('#') if ($h.Length -lt 6) { continue } $r = [Convert]::ToInt32($h.Substring(0,2), 16) $g = [Convert]::ToInt32($h.Substring(2,2), 16) $b = [Convert]::ToInt32($h.Substring(4,2), 16) [void]$sb.Append([char]27).Append("[48;2;${r};${g};${b}m ").Append([char]27).Append('[49m ') } [void]$sb.Append([char]27).Append('[0m') return $sb.ToString() } function Convert-HueToRgb { # HSL hue helper. Internal to Convert-HexAdjust. param([double]$P, [double]$Q, [double]$T) if ($T -lt 0) { $T += 1.0 } if ($T -gt 1) { $T -= 1.0 } if ($T -lt (1.0/6.0)) { return $P + ($Q - $P) * 6.0 * $T } if ($T -lt (1.0/2.0)) { return $Q } if ($T -lt (2.0/3.0)) { return $P + ($Q - $P) * ((2.0/3.0) - $T) * 6.0 } return $P } function Convert-HexAdjust { # hex -> RGB -> HSL -> adjust (L additive, S multiplicative) -> RGB -> hex. # Brightness/Saturation in -100..+100. Preserves a leading '#'. Lowercase out. param( [Parameter(Mandatory)][string]$Hex, [int]$Brightness = 0, [int]$Saturation = 0 ) $hadHash = $Hex.StartsWith('#') $h = $Hex.TrimStart('#') $r = [Convert]::ToInt32($h.Substring(0,2),16) / 255.0 $g = [Convert]::ToInt32($h.Substring(2,2),16) / 255.0 $b = [Convert]::ToInt32($h.Substring(4,2),16) / 255.0 $max = [Math]::Max($r, [Math]::Max($g, $b)) $min = [Math]::Min($r, [Math]::Min($g, $b)) $l = ($max + $min) / 2.0 $d = $max - $min if ($d -eq 0) { $hh = 0.0; $s = 0.0 } else { $s = if ($l -gt 0.5) { $d / (2.0 - $max - $min) } else { $d / ($max + $min) } if ($max -eq $r) { $hh = (($g - $b) / $d) % 6 } elseif ($max -eq $g) { $hh = (($b - $r) / $d) + 2 } else { $hh = (($r - $g) / $d) + 4 } $hh = $hh * 60.0 if ($hh -lt 0) { $hh += 360.0 } } $l = [Math]::Max(0.0, [Math]::Min(1.0, $l + ($Brightness / 100.0) * 0.5)) $s = [Math]::Max(0.0, [Math]::Min(1.0, $s * (1.0 + ($Saturation / 100.0)))) if ($s -eq 0) { $r2 = $l; $g2 = $l; $b2 = $l } else { $q = if ($l -lt 0.5) { $l * (1.0 + $s) } else { $l + $s - $l * $s } $p = 2.0 * $l - $q $hk = $hh / 360.0 $r2 = Convert-HueToRgb -P $p -Q $q -T ($hk + 1.0/3.0) $g2 = Convert-HueToRgb -P $p -Q $q -T $hk $b2 = Convert-HueToRgb -P $p -Q $q -T ($hk - 1.0/3.0) } $ri = [int][Math]::Round($r2 * 255.0) $gi = [int][Math]::Round($g2 * 255.0) $bi = [int][Math]::Round($b2 * 255.0) $out = '{0:x2}{1:x2}{2:x2}' -f $ri, $gi, $bi if ($hadHash) { return "#$out" } else { return $out } } function Get-AdjustedScheme { # Returns a NEW scheme object (does not mutate $Scheme) with every hex # color slot adjusted by the brightness/saturation deltas in HSL space. # Non-color props (name, etc.) pass through. Missing slots skipped; # malformed hex passed through unchanged. param( [Parameter(Mandatory)]$Scheme, [int]$Brightness = 0, [int]$Saturation = 0 ) $slots = @('background','foreground','cursorColor','selectionBackground', 'black','red','green','yellow','blue','purple','cyan','white', 'brightBlack','brightRed','brightGreen','brightYellow', 'brightBlue','brightPurple','brightCyan','brightWhite') $out = [pscustomobject]@{} foreach ($prop in $Scheme.PSObject.Properties) { $name = $prop.Name $val = $prop.Value # Normalised first, so shorthand and 8-digit hex move with the knobs # instead of freezing while their neighbours adjust. Anything the # normaliser does not understand is still passed through untouched. if (($name -in $slots) -and ($val -is [string])) { $norm = ConvertTo-NormalHex -Hex $val if ($norm) { $adjusted = Convert-HexAdjust -Hex $norm -Brightness $Brightness -Saturation $Saturation # Preserve the author's leading-# convention for this slot. $val = if (([string]$val).StartsWith('#')) { $adjusted } else { $adjusted.TrimStart('#') } } } $out | Add-Member -NotePropertyName $name -NotePropertyValue $val -Force } return $out } function Get-SchemeOscPacket { # Returns a single string of OSC escape sequences that, when written to # stdout, instantly retints the terminal's fg/bg/cursor/selection + the # 16-color palette to $Scheme -- no settings.json write, no WT reload. # Extracted from the picker so the tuner reuses the exact same format. param([Parameter(Mandatory)]$Scheme) $BEL = [char]7 $E = [char]27 $palette = 'black','red','green','yellow','blue','purple','cyan','white', 'brightBlack','brightRed','brightGreen','brightYellow', 'brightBlue','brightPurple','brightCyan','brightWhite' # Every value is normalised before it reaches the string. A scheme slot is # attacker-influenced input -- README documents dropping a third-party style # folder into the styles dir -- and this packet is not just written to the # terminal once: Set-ShellStyleState persists it to current-style.osc, which # every new zsh/bash shell replays. A background of # "#000000<BEL><ESC>]52;c;..." closed OSC 11 early and made the remainder a # second, attacker-chosen sequence, re-executed on every shell start. # ConvertTo-NormalHex admits only hex, so nothing can escape the sequence. $sb = [System.Text.StringBuilder]::new() $fg = ConvertTo-NormalHex -Hex $Scheme.foreground $bg = ConvertTo-NormalHex -Hex $Scheme.background $cur = ConvertTo-NormalHex -Hex $Scheme.cursorColor $sel = ConvertTo-NormalHex -Hex $Scheme.selectionBackground if ($fg) { [void]$sb.Append("$E]10;$fg$BEL") } if ($bg) { [void]$sb.Append("$E]11;$bg$BEL") } if ($cur) { [void]$sb.Append("$E]12;$cur$BEL") } if ($sel) { [void]$sb.Append("$E]17;$sel$BEL") } for ($p = 0; $p -lt $palette.Count; $p++) { $color = ConvertTo-NormalHex -Hex $Scheme.($palette[$p]) if ($color) { [void]$sb.Append("$E]4;${p};${color}$BEL") } } return $sb.ToString() } function Get-OscResetPacket { # Returns OSC sequences that RESET the terminal's dynamic colors back to the # profile defaults: the full palette (OSC 104, no params) plus foreground # (110), background (111), cursor (112), and selection (117). The picker and # tuner retint live via Get-SchemeOscPacket; on Esc/cancel those overrides # would otherwise persist over the reverted settings.json, so we emit this to # hand color control back to Windows Terminal's configured scheme. $BEL = [char]7 $E = [char]27 return "$E]104$BEL" + "$E]110$BEL" + "$E]111$BEL" + "$E]112$BEL" + "$E]117$BEL" } |